diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 54ababd..b555e18 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -63,6 +63,7 @@ It is intentionally implementation-focused: | 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. | | 2026-03-21 | P3.1 | Completed | Split `Tables.razor` into focused table components for the selector header, context bar, canvas, and legend while leaving loading, deep-link synchronization, and dialog state in the page host. | | 2026-03-21 | P3.2 | Completed | Replaced the floating table picker with a permanent left-rail layout, converted the old selector component into a real page header, and kept the current selection flow intact inside the new reference frame. | +| 2026-03-21 | P3.3 | Completed | Added rail search, family filters, pinned and recent sections, curated status chips, and keyboard up/down plus Enter handling on top of the new permanent table index rail. | ### Lessons Learned @@ -95,6 +96,7 @@ It is intentionally implementation-focused: - 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. - The `Tables` rewrite is safer when orchestration and rendering are separated early. Keeping loading, persistence, and dialog state in the page host while extracting render-only components makes later layout and interaction changes much lower risk. - The `Tables` navigation model needs its own persistent geometry before advanced behaviors land. Converting the selector to a real rail first keeps later search and keyboard work from being tangled up with another structural rewrite. +- Rail keyboard behavior is easiest to maintain when it works from one deduplicated option order, even if the UI shows multiple sections. Keeping one internal option list avoids separate arrow-key state per section. ## Target Outcomes @@ -457,7 +459,7 @@ Build the shared interaction infrastructure needed by multiple destinations befo | --- | --- | --- | | `P3.1` | Completed | `Tables.razor` now acts as the stateful host while selector/header/canvas/legend rendering lives in dedicated `Components/Tables` components. | | `P3.2` | Completed | The old dropdown picker is gone; `/tables` now uses a permanent left rail and a real page header while keeping the current selection flow intact. | -| `P3.3` | Pending | Add search, keyboard navigation, pinned/recent sections, curated percentage chips, and family filtering to the rail. | +| `P3.3` | Completed | The rail now supports search-as-you-type, family filters, pinned and recent sections, curated status chips, and a deduplicated arrow/Enter keyboard path. | | `P3.4` | Pending | Introduce the sticky context bar with roll jump and mode/filter controls. | | `P3.5` | Pending | Rework the canvas for sticky headers, sticky roll bands, stronger reading emphasis, and density control. | | `P3.6` | Pending | Remove visible resting-state action stacks from non-selected cells. | diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 9047abe..b1be804 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -28,6 +28,8 @@ diff --git a/src/RolemasterDb.App/Components/Tables/TablesIndexRail.razor b/src/RolemasterDb.App/Components/Tables/TablesIndexRail.razor index d39093b..dcb3242 100644 --- a/src/RolemasterDb.App/Components/Tables/TablesIndexRail.razor +++ b/src/RolemasterDb.App/Components/Tables/TablesIndexRail.razor @@ -4,46 +4,319 @@

Choose a table, then read from the roll band across to the result you need.

-
- @foreach (var table in Tables) +
+ + + + @if (familyFilters.Count > 1) { - +
+ @foreach (var family in familyFilters) + { + var isAllFilter = string.IsNullOrEmpty(family); + var label = isAllFilter ? "All families" : family; + + + } +
+ } +
+ + @if (pinnedTables.Count > 0) + { +
+
+

Pinned

+ @pinnedTables.Count +
+
+ @foreach (var table in pinnedTables) + { + @RenderTableOption(table) + } +
+
+ } + + @if (recentTables.Count > 0) + { +
+
+

Recent

+ @recentTables.Count +
+
+ @foreach (var table in recentTables) + { + @RenderTableOption(table) + } +
+
+ } + +
+
+

All tables

+ @filteredTables.Count +
+ + @if (filteredTables.Count == 0) + { +

No tables match the current search.

+ } + else + { +
+ @foreach (var table in filteredTables) + { + @RenderTableOption(table) + } +
}
@code { + private readonly List familyFilters = new(); + private readonly List filteredTables = new(); + private readonly List pinnedTables = new(); + private readonly List recentTables = new(); + private readonly List keyboardOptions = new(); + private string searchText = string.Empty; + private string selectedFamily = string.Empty; + private string? activeOptionSlug; + [Parameter] public IReadOnlyList Tables { get; set; } = Array.Empty(); [Parameter] public string SelectedTableSlug { get; set; } = string.Empty; + [Parameter] + public IReadOnlyList PinnedTableSlugs { get; set; } = Array.Empty(); + + [Parameter] + public IReadOnlyList RecentTableSlugs { get; set; } = Array.Empty(); + [Parameter] public Func? IsPinned { get; set; } [Parameter] public EventCallback OnSelectTable { get; set; } + protected override void OnParametersSet() + { + BuildFamilyFilters(); + BuildSections(); + EnsureActiveOption(); + } + private bool GetIsPinned(string tableSlug) => IsPinned?.Invoke(tableSlug) ?? false; + private void BuildFamilyFilters() + { + familyFilters.Clear(); + familyFilters.Add(string.Empty); + + foreach (var family in Tables + .Select(table => table.Family) + .Where(family => !string.IsNullOrWhiteSpace(family)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(family => family, StringComparer.OrdinalIgnoreCase)) + { + familyFilters.Add(family); + } + } + + private void BuildSections() + { + filteredTables.Clear(); + pinnedTables.Clear(); + recentTables.Clear(); + keyboardOptions.Clear(); + + foreach (var table in Tables) + { + if (!MatchesFilters(table)) + { + continue; + } + + filteredTables.Add(table); + } + + foreach (var slug in PinnedTableSlugs) + { + var table = filteredTables.FirstOrDefault(item => string.Equals(item.Key, slug, StringComparison.OrdinalIgnoreCase)); + if (table is not null) + { + pinnedTables.Add(table); + } + } + + foreach (var slug in RecentTableSlugs) + { + var table = filteredTables.FirstOrDefault(item => string.Equals(item.Key, slug, StringComparison.OrdinalIgnoreCase)); + if (table is not null && pinnedTables.All(item => !string.Equals(item.Key, table.Key, StringComparison.OrdinalIgnoreCase))) + { + recentTables.Add(table); + } + } + + foreach (var table in pinnedTables) + { + AddKeyboardOption(table); + } + + foreach (var table in recentTables) + { + AddKeyboardOption(table); + } + + foreach (var table in filteredTables) + { + AddKeyboardOption(table); + } + } + + private void AddKeyboardOption(CriticalTableReference table) + { + if (keyboardOptions.Any(item => string.Equals(item.Key, table.Key, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + keyboardOptions.Add(table); + } + + private bool MatchesFilters(CriticalTableReference table) + { + if (!string.IsNullOrEmpty(selectedFamily) && + !string.Equals(table.Family, selectedFamily, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(searchText)) + { + return true; + } + + return table.Label.Contains(searchText, StringComparison.OrdinalIgnoreCase) + || table.Key.Contains(searchText, StringComparison.OrdinalIgnoreCase) + || table.Family.Contains(searchText, StringComparison.OrdinalIgnoreCase); + } + + private void HandleSearchInput(ChangeEventArgs args) + { + searchText = args.Value?.ToString()?.Trim() ?? string.Empty; + BuildSections(); + EnsureActiveOption(); + } + + private void SelectFamilyFilter(string family) + { + selectedFamily = family; + BuildSections(); + EnsureActiveOption(); + } + + private bool IsFamilyFilterSelected(string family) => + string.Equals(selectedFamily, family, StringComparison.OrdinalIgnoreCase); + + private string GetFamilyFilterCssClass(string family) => + IsFamilyFilterSelected(family) + ? "tables-family-filter is-selected" + : "tables-family-filter"; + + private void HandleRailKeyDown(KeyboardEventArgs args) + { + if (keyboardOptions.Count == 0) + { + return; + } + + if (string.Equals(args.Key, "ArrowDown", StringComparison.Ordinal)) + { + MoveActiveOption(1); + return; + } + + if (string.Equals(args.Key, "ArrowUp", StringComparison.Ordinal)) + { + MoveActiveOption(-1); + return; + } + + if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(activeOptionSlug)) + { + _ = OnSelectTable.InvokeAsync(activeOptionSlug); + } + } + + private void MoveActiveOption(int offset) + { + if (keyboardOptions.Count == 0) + { + activeOptionSlug = null; + return; + } + + var currentIndex = keyboardOptions.FindIndex(item => string.Equals(item.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase)); + if (currentIndex < 0) + { + currentIndex = keyboardOptions.FindIndex(item => string.Equals(item.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)); + } + + var nextIndex = currentIndex < 0 + ? 0 + : Math.Clamp(currentIndex + offset, 0, keyboardOptions.Count - 1); + + activeOptionSlug = keyboardOptions[nextIndex].Key; + } + + private void EnsureActiveOption() + { + if (keyboardOptions.Count == 0) + { + activeOptionSlug = null; + return; + } + + if (!string.IsNullOrWhiteSpace(activeOptionSlug) && + keyboardOptions.Any(item => string.Equals(item.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + if (!string.IsNullOrWhiteSpace(SelectedTableSlug)) + { + var selectedOption = keyboardOptions.FirstOrDefault(item => string.Equals(item.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)); + if (selectedOption is not null) + { + activeOptionSlug = selectedOption.Key; + return; + } + } + + activeOptionSlug = keyboardOptions[0].Key; + } + + private string GetCurationTone(CriticalTableReference table) => + table.CurationPercentage >= 100 ? "success" : "warning"; + private string GetTableOptionCssClass(CriticalTableReference table) { var classes = new List(); @@ -53,7 +326,34 @@ classes.Add("is-selected"); } + if (string.Equals(table.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase)) + { + classes.Add("is-active"); + } + classes.Add(table.CurationPercentage >= 100 ? "is-curated" : "needs-curation"); return string.Join(' ', classes); } + + private void SetActiveOption(string tableSlug) => activeOptionSlug = tableSlug; + + private RenderFragment RenderTableOption(CriticalTableReference table) => @; } diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index f16c9bc..69c66b4 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -1171,6 +1171,57 @@ pre, gap: 0.35rem; } +.tables-index-controls { + display: grid; + gap: 0.75rem; +} + +.tables-index-search-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.tables-index-search { + width: 100%; +} + +.tables-family-filters { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tables-family-filter { + border: 1px solid rgba(127, 96, 55, 0.2); + border-radius: 999px; + background: rgba(255, 250, 242, 0.86); + color: var(--ink-soft); + font-size: 0.8rem; + padding: 0.28rem 0.6rem; + transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease; +} + +.tables-family-filter:hover, +.tables-family-filter:focus-visible { + border-color: rgba(184, 121, 59, 0.3); + background: rgba(255, 244, 228, 0.94); + color: var(--ink); + outline: none; +} + +.tables-family-filter.is-selected { + background: rgba(188, 117, 43, 0.16); + border-color: rgba(188, 117, 43, 0.3); + color: var(--ink-strong); +} + .tables-index-title { margin: 0; color: var(--ink-strong); @@ -1183,6 +1234,35 @@ pre, gap: 0.35rem; } +.tables-index-section { + display: grid; + gap: 0.5rem; +} + +.tables-index-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.tables-index-section-header h3 { + margin: 0; + font-size: 0.95rem; + color: var(--ink-strong); +} + +.tables-index-section-count { + color: var(--ink-soft); + font-size: 0.78rem; +} + +.tables-index-empty { + margin: 0; + color: var(--ink-soft); + font-size: 0.9rem; +} + .table-index-option { display: flex; align-items: flex-start; @@ -1210,6 +1290,10 @@ pre, background: rgba(248, 238, 221, 0.98); } +.table-index-option.is-active { + box-shadow: inset 0 0 0 1px rgba(15, 148, 136, 0.3); +} + .table-index-option.is-curated { background: rgba(102, 138, 83, 0.12); }