diff --git a/assets/js/app.js b/assets/js/app.js index 17a44e5d..89c2e250 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,12 +24,14 @@ import { hooks as colocatedHooks } from "phoenix-colocated/music_library"; import topbar from "../vendor/topbar"; import { Hooks as FluxonHooks, DOM as FluxonDOM } from "fluxon"; import FormatNumberHook from "./hooks/format-number"; +import UniversalSearchNavigationHook from "./hooks/universal-search-navigation"; import confetti from "canvas-confetti"; import { createLiveToastHook } from "live_toast"; import banner from "./banner"; let Hooks = FluxonHooks; Hooks.FormatNumber = FormatNumberHook; +Hooks.UniversalSearchNavigation = UniversalSearchNavigationHook; Hooks.LiveToast = createLiveToastHook(); const csrfToken = document diff --git a/assets/js/hooks/universal-search-navigation.js b/assets/js/hooks/universal-search-navigation.js new file mode 100644 index 00000000..bfb9dea7 --- /dev/null +++ b/assets/js/hooks/universal-search-navigation.js @@ -0,0 +1,122 @@ +export default { + mounted() { + this.searchInput = document.getElementById("universal-search-input"); + this.selectedIndex = -1; // -1 means search input is focused + + this.keydownHandler = (event) => { + // Only handle keyboard navigation when the modal is open + const modal = document.getElementById("universal-search-root"); + if (!modal) return; + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + this.navigateDown(); + break; + case "ArrowUp": + event.preventDefault(); + this.navigateUp(); + break; + case "Enter": + if (this.selectedIndex >= 0) { + event.preventDefault(); + this.selectCurrentResult(); + } + break; + case "Escape": + // Let the modal's built-in escape handling work + break; + } + }; + + document.addEventListener("keydown", this.keydownHandler); + }, + + updated() { + // Reset selection when results change + this.selectedIndex = -1; + this.updateSelection(); + }, + + destroyed() { + if (this.keydownHandler) { + document.removeEventListener("keydown", this.keydownHandler); + } + }, + + navigateDown() { + const results = this.getResultElements(); + + if (results.length === 0) return; + + // Move to next result or wrap to search input + if (this.selectedIndex < results.length - 1) { + this.selectedIndex++; + } else { + // Wrap back to search input + this.selectedIndex = -1; + } + + this.updateSelection(); + }, + + navigateUp() { + const results = this.getResultElements(); + + if (results.length === 0) return; + + // Move to previous result or wrap to last result + if (this.selectedIndex > -1) { + this.selectedIndex--; + } else { + // Wrap to last result + this.selectedIndex = results.length - 1; + } + + this.updateSelection(); + }, + + selectCurrentResult() { + const results = this.getResultElements(); + + if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { + const selectedResult = results[this.selectedIndex]; + // Trigger the click event on the result + selectedResult.click(); + } + }, + + updateSelection() { + const results = this.getResultElements(); + + // Remove all aria-selected attributes and return focus to search input + results.forEach((result) => { + result.removeAttribute("aria-selected"); + }); + + if (this.selectedIndex === -1) { + // Focus search input + if (this.searchInput) { + this.searchInput.focus(); + } + } else if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { + // Mark selected result + const selectedResult = results[this.selectedIndex]; + selectedResult.setAttribute("aria-selected", "true"); + + // Scroll into view if needed + selectedResult.scrollIntoView({ + block: "nearest", + behavior: "smooth" + }); + } + }, + + getResultElements() { + // Get all result items with role="option" + const modal = document.getElementById("universal-search-root"); + if (!modal) return []; + + return Array.from(modal.querySelectorAll('[role="option"]')); + } +}; diff --git a/lib/music_library_web/components/search_components.ex b/lib/music_library_web/components/search_components.ex index a7498c00..93930f6c 100644 --- a/lib/music_library_web/components/search_components.ex +++ b/lib/music_library_web/components/search_components.ex @@ -182,6 +182,15 @@ defmodule MusicLibraryWeb.SearchComponents do
+
0} class="flex items-center"> + + + {gettext("Navigate")} +
+
0} class="flex items-center"> + Enter + {gettext("Select")} +
Esc {gettext("Close")} diff --git a/lib/music_library_web/live/universal_search_live/index.html.heex b/lib/music_library_web/live/universal_search_live/index.html.heex index 02279c9b..cc3d4fac 100644 --- a/lib/music_library_web/live/universal_search_live/index.html.heex +++ b/lib/music_library_web/live/universal_search_live/index.html.heex @@ -1,4 +1,4 @@ -
+
<.structured_modal :if={@show_modal} id="universal-search-root"