GlobalSearch.razor 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. @* Global resource search — header-level palette.
  2. Keyboard: ↑/↓ move the highlight, Enter navigates, Esc clears.
  3. Blur: Clicking anywhere outside the input closes the dropdown.
  4. *@
  5. @using Microsoft.AspNetCore.Components.Web
  6. @using RackPeek.Domain.Persistence
  7. @using RackPeek.Domain.Resources
  8. @using Shared.Rcl.Services
  9. @inject IResourceCollection Repo
  10. @inject NavigationManager Nav
  11. <div class="relative">
  12. <input type="search"
  13. data-testid="global-search-input"
  14. placeholder="Search resources…"
  15. autocomplete="off"
  16. class="bg-zinc-800 text-zinc-200 placeholder-zinc-500
  17. border border-zinc-700 rounded
  18. px-3 py-1 text-sm w-72
  19. focus:outline-none focus:border-emerald-400"
  20. @bind="_query"
  21. @bind:event="oninput"
  22. @bind:after="RunSearchAsync"
  23. @onkeydown="OnKeyDown"
  24. @onfocusout="OnFocusOut"/>
  25. @if (!string.IsNullOrWhiteSpace(_query))
  26. {
  27. <div data-testid="global-search-results"
  28. class="absolute left-0 right-0 mt-1 z-50
  29. bg-zinc-900 border border-zinc-700 rounded
  30. shadow-lg max-h-96 overflow-y-auto text-sm">
  31. @if (_results.Count == 0)
  32. {
  33. <div data-testid="global-search-no-results"
  34. class="px-3 py-2 text-zinc-500 italic">
  35. No matches.
  36. </div>
  37. }
  38. else
  39. {
  40. @for (var i = 0; i < _results.Count; i++)
  41. {
  42. var r = _results[i];
  43. var highlighted = i == _highlightedIndex;
  44. var rowClass = highlighted
  45. ? "bg-zinc-800 border-l-2 border-emerald-400"
  46. : "hover:bg-zinc-800 border-l-2 border-transparent";
  47. <button type="button"
  48. data-testid="@($"global-search-result-{Sanitize(r.Kind)}-{Sanitize(r.Name)}")"
  49. @onclick="() => OnResultSelected(r)"
  50. 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}")">
  51. <div class="font-medium text-emerald-400">@r.Name</div>
  52. <div data-testid="global-search-result-match"
  53. class="text-xs text-zinc-500">
  54. @r.Kind · via @r.MatchedField: @r.MatchedValue
  55. </div>
  56. </button>
  57. }
  58. }
  59. </div>
  60. }
  61. </div>
  62. @code {
  63. // Brief delay on focus-out so a click on a result button can fire first.
  64. // Browser event order is mousedown → blur → click, so the focus-out fires
  65. // before the click handler on the button. The delay lets the click register
  66. // before we close the dropdown.
  67. private const int _blurCloseDelayMs = 150;
  68. private string _query = string.Empty;
  69. private IReadOnlyList<SearchResult> _results = [];
  70. private int _highlightedIndex;
  71. private async Task RunSearchAsync()
  72. {
  73. // Reload every search so newly added / edited / deleted resources show up
  74. // without a hard page refresh. The repo layer is already in-memory, so
  75. // this is a cheap dictionary lookup, not a disk read.
  76. var resources = await Repo.GetAllOfTypeAsync<Resource>();
  77. _results = GlobalSearchService.Search(resources, _query);
  78. _highlightedIndex = 0;
  79. }
  80. private void OnKeyDown(KeyboardEventArgs e)
  81. {
  82. switch (e.Key)
  83. {
  84. case "ArrowDown":
  85. if (_results.Count > 0)
  86. _highlightedIndex = Math.Min(_highlightedIndex + 1, _results.Count - 1);
  87. break;
  88. case "ArrowUp":
  89. if (_results.Count > 0)
  90. _highlightedIndex = Math.Max(_highlightedIndex - 1, 0);
  91. break;
  92. case "Enter":
  93. if (_highlightedIndex >= 0 && _highlightedIndex < _results.Count)
  94. OnResultSelected(_results[_highlightedIndex]);
  95. break;
  96. case "Escape":
  97. Close();
  98. break;
  99. }
  100. }
  101. private async Task OnFocusOut()
  102. {
  103. await Task.Delay(_blurCloseDelayMs);
  104. if (!string.IsNullOrEmpty(_query))
  105. {
  106. Close();
  107. StateHasChanged();
  108. }
  109. }
  110. private void OnResultSelected(SearchResult r)
  111. {
  112. Close();
  113. Nav.NavigateTo(r.Url);
  114. }
  115. private void Close()
  116. {
  117. _query = string.Empty;
  118. _results = [];
  119. _highlightedIndex = 0;
  120. }
  121. private static string Sanitize(string value) =>
  122. value.Replace(" ", "-").ToLowerInvariant();
  123. }