From 15615d27a4d90f5a22aa5526ab64b3045ebe3278 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Tue, 10 Feb 2026 19:29:16 +0000 Subject: [PATCH] Refactor keyboard navigation hooks into one --- assets/js/hooks/create-navigation-hook.js | 110 +++++++++++++++ assets/js/hooks/record-picker-navigation.js | 118 ++-------------- .../js/hooks/universal-search-navigation.js | 126 +----------------- 3 files changed, 124 insertions(+), 230 deletions(-) create mode 100644 assets/js/hooks/create-navigation-hook.js diff --git a/assets/js/hooks/create-navigation-hook.js b/assets/js/hooks/create-navigation-hook.js new file mode 100644 index 00000000..0707138c --- /dev/null +++ b/assets/js/hooks/create-navigation-hook.js @@ -0,0 +1,110 @@ +export default function createNavigationHook({ getContainer, inputId, onSelect }) { + return { + mounted() { + this.selectedIndex = -1; + + this.keydownHandler = (event) => { + const container = getContainer(this); + if (!container) 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; + } + }; + + document.addEventListener("keydown", this.keydownHandler); + }, + + updated() { + this.selectedIndex = -1; + this.updateSelection(); + }, + + destroyed() { + if (this.keydownHandler) { + document.removeEventListener("keydown", this.keydownHandler); + } + }, + + navigateDown() { + const results = this.getResultElements(); + if (results.length === 0) return; + + if (this.selectedIndex < results.length - 1) { + this.selectedIndex++; + } else { + this.selectedIndex = -1; + } + + this.updateSelection(); + }, + + navigateUp() { + const results = this.getResultElements(); + if (results.length === 0) return; + + if (this.selectedIndex > -1) { + this.selectedIndex--; + } else { + this.selectedIndex = results.length - 1; + } + + this.updateSelection(); + }, + + selectCurrentResult() { + const results = this.getResultElements(); + + if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { + results[this.selectedIndex].click(); + + if (onSelect) { + onSelect(this); + } + } + }, + + updateSelection() { + const results = this.getResultElements(); + + results.forEach((result) => { + result.removeAttribute("aria-selected"); + }); + + if (this.selectedIndex === -1) { + const searchInput = document.getElementById(inputId); + if (searchInput) { + searchInput.focus(); + } + } else if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { + const selectedResult = results[this.selectedIndex]; + selectedResult.setAttribute("aria-selected", "true"); + + selectedResult.scrollIntoView({ + block: "nearest", + behavior: "smooth" + }); + } + }, + + getResultElements() { + const container = getContainer(this); + if (!container) return []; + + return Array.from(container.querySelectorAll('[role="option"]')); + } + }; +} diff --git a/assets/js/hooks/record-picker-navigation.js b/assets/js/hooks/record-picker-navigation.js index 54da56c6..e139f044 100644 --- a/assets/js/hooks/record-picker-navigation.js +++ b/assets/js/hooks/record-picker-navigation.js @@ -1,113 +1,13 @@ -export default { - mounted() { - this.selectedIndex = -1; // -1 means search input is focused +import createNavigationHook from "./create-navigation-hook"; - this.keydownHandler = (event) => { - const modal = document.getElementById("record-picker-modal"); - 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; - } - }; - - 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; - - if (this.selectedIndex < results.length - 1) { - this.selectedIndex++; +export default createNavigationHook({ + getContainer: (hook) => hook.el, + inputId: "record-picker-search-input", + onSelect: (hook) => { + if (hook.selectedIndex === 0) { + hook.navigateDown(); } else { - this.selectedIndex = -1; + hook.navigateUp(); } - - this.updateSelection(); - }, - - navigateUp() { - const results = this.getResultElements(); - - if (results.length === 0) return; - - if (this.selectedIndex > -1) { - this.selectedIndex--; - } else { - this.selectedIndex = results.length - 1; - } - - this.updateSelection(); - }, - - selectCurrentResult() { - const results = this.getResultElements(); - - if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { - results[this.selectedIndex].click(); - - if (this.selectedIndex === 0) { - this.navigateDown(); - } else { - this.naigateUp(); - } - } - }, - - updateSelection() { - const results = this.getResultElements(); - - results.forEach((result) => { - result.removeAttribute("aria-selected"); - }); - - if (this.selectedIndex === -1) { - const searchInput = document.getElementById("record-picker-search-input"); - if (searchInput) { - searchInput.focus(); - } - } else if (this.selectedIndex >= 0 && this.selectedIndex < results.length) { - const selectedResult = results[this.selectedIndex]; - selectedResult.setAttribute("aria-selected", "true"); - - selectedResult.scrollIntoView({ - block: "nearest", - behavior: "smooth" - }); - } - }, - - getResultElements() { - const container = this.el; - if (!container) return []; - - return Array.from(container.querySelectorAll('[role="option"]')); } -}; +}); diff --git a/assets/js/hooks/universal-search-navigation.js b/assets/js/hooks/universal-search-navigation.js index 7efa8706..45bd48f7 100644 --- a/assets/js/hooks/universal-search-navigation.js +++ b/assets/js/hooks/universal-search-navigation.js @@ -1,122 +1,6 @@ -export default { - mounted() { - this.selectedIndex = -1; // -1 means search input is focused +import createNavigationHook from "./create-navigation-hook"; - 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 - results.forEach((result) => { - result.removeAttribute("aria-selected"); - }); - - if (this.selectedIndex === -1) { - // Focus search input - const searchInput = document.getElementById("universal-search-input"); - if (searchInput) { - 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"]')); - } -}; +export default createNavigationHook({ + getContainer: () => document.getElementById("universal-search-root"), + inputId: "universal-search-input" +});