GlobalSearch.razor 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. @* Global resource search — header-level palette.
  2. Keyboard: ↑/↓ move the highlight, Enter navigates, Esc clears.
  3. *@
  4. @using Microsoft.AspNetCore.Components.Web
  5. @using RackPeek.Domain.Persistence
  6. @using RackPeek.Domain.Resources
  7. @using Shared.Rcl.Services
  8. @inject IResourceCollection Repo
  9. @inject NavigationManager Nav
  10. <div class="relative">
  11. <input type="search"
  12. data-testid="global-search-input"
  13. placeholder="Search resources…"
  14. autocomplete="off"
  15. class="bg-zinc-800 text-zinc-200 placeholder-zinc-500
  16. border border-zinc-700 rounded
  17. px-3 py-1 text-sm w-72
  18. focus:outline-none focus:border-emerald-400"
  19. @bind="_query"
  20. @bind:event="oninput"
  21. @bind:after="RunSearchAsync"
  22. @onkeydown="OnKeyDown"/>
  23. @if (!string.IsNullOrWhiteSpace(_query))
  24. {
  25. <div data-testid="global-search-results"
  26. class="absolute left-0 right-0 mt-1 z-50
  27. bg-zinc-900 border border-zinc-700 rounded
  28. shadow-lg max-h-96 overflow-y-auto text-sm">
  29. @if (_results.Count == 0)
  30. {
  31. <div data-testid="global-search-no-results"
  32. class="px-3 py-2 text-zinc-500 italic">
  33. No matches.
  34. </div>
  35. }
  36. else
  37. {
  38. @for (var i = 0; i < _results.Count; i++)
  39. {
  40. var r = _results[i];
  41. var highlighted = i == _highlightedIndex;
  42. var rowClass = highlighted
  43. ? "bg-zinc-800 border-l-2 border-emerald-400"
  44. : "hover:bg-zinc-800 border-l-2 border-transparent";
  45. <button type="button"
  46. data-testid="@($"global-search-result-{Sanitize(r.Kind)}-{Sanitize(r.Name)}")"
  47. @onclick="() => OnResultSelected(r)"
  48. class="@($"w-full text-left block px-3 py-2 text-zinc-200 border-b border-zinc-800 last:border-b-0 cursor-pointer {rowClass}")">
  49. <div class="font-medium text-emerald-400">@r.Name</div>
  50. <div data-testid="global-search-result-match"
  51. class="text-xs text-zinc-500">
  52. @r.Kind · via @r.MatchedField: @r.MatchedValue
  53. </div>
  54. </button>
  55. }
  56. }
  57. </div>
  58. }
  59. </div>
  60. @code {
  61. private string _query = string.Empty;
  62. private IReadOnlyList<SearchResult> _results = [];
  63. private IReadOnlyList<Resource>? _resources;
  64. private int _highlightedIndex;
  65. private async Task RunSearchAsync()
  66. {
  67. _resources ??= await Repo.GetAllOfTypeAsync<Resource>();
  68. _results = GlobalSearchService.Search(_resources, _query);
  69. _highlightedIndex = 0;
  70. }
  71. private void OnKeyDown(KeyboardEventArgs e)
  72. {
  73. switch (e.Key)
  74. {
  75. case "ArrowDown":
  76. if (_results.Count > 0)
  77. _highlightedIndex = Math.Min(_highlightedIndex + 1, _results.Count - 1);
  78. break;
  79. case "ArrowUp":
  80. if (_results.Count > 0)
  81. _highlightedIndex = Math.Max(_highlightedIndex - 1, 0);
  82. break;
  83. case "Enter":
  84. if (_highlightedIndex >= 0 && _highlightedIndex < _results.Count)
  85. OnResultSelected(_results[_highlightedIndex]);
  86. break;
  87. case "Escape":
  88. _query = string.Empty;
  89. _results = [];
  90. _highlightedIndex = 0;
  91. break;
  92. }
  93. }
  94. private void OnResultSelected(SearchResult r)
  95. {
  96. _query = string.Empty;
  97. _results = [];
  98. _highlightedIndex = 0;
  99. Nav.NavigateTo(r.Url);
  100. }
  101. private static string Sanitize(string value) =>
  102. value.Replace(" ", "-").ToLowerInvariant();
  103. }