From aad6b843aa0c1a99eb7122c874908a6d05f7471b Mon Sep 17 00:00:00 2001 From: elracing Date: Mon, 27 Jul 2026 02:45:50 -0400 Subject: [PATCH] Integrated j/k for package navigation, included some unit tests --- app/pages/search.vue | 53 +++++++++++-------- app/utils/search-navigation.ts | 20 +++++++ test/unit/app/utils/search-navigation.spec.ts | 35 ++++++++++++ 3 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 app/utils/search-navigation.ts create mode 100644 test/unit/app/utils/search-navigation.spec.ts diff --git a/app/pages/search.vue b/app/pages/search.vue index 9010734a57..dfe7a45928 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -5,6 +5,8 @@ import { onKeyDown } from '@vueuse/core' import { debounce } from 'perfect-debounce' import { isValidNewPackageName } from '~/utils/package-name' import { isPlatformSpecificPackage } from '~/utils/platform-packages' +import { isEditableElement } from '~/utils/input' +import { getSearchResultNavigationDirection } from '~/utils/search-navigation' import { normalizeSearchParam } from '#shared/utils/url' definePageMeta({ @@ -490,9 +492,10 @@ function focusSearchInput() { const keyboardShortcuts = useKeyboardShortcuts() function handleResultsKeydown(e: KeyboardEvent) { - if (!keyboardShortcuts.value) { + if (!keyboardShortcuts.value || isEditableElement(e.target)) { return } + // If the active element is an input, navigate to exact match or wait for results if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') { // Get value directly from input (not from route query, which may be debounced) @@ -516,37 +519,41 @@ function handleResultsKeydown(e: KeyboardEvent) { if (totalSelectableCount.value <= 0) return - const elements = getFocusableElements() - if (elements.length === 0) return + const direction = getSearchResultNavigationDirection(e.key) + if (direction) { + e.preventDefault() + const elements = getFocusableElements() + if (elements.length === 0) return - const currentIndex = elements.findIndex(el => el === document.activeElement) + const currentIndex = elements.findIndex(el => el === document.activeElement) - if (e.key === 'ArrowDown') { - e.preventDefault() - const nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, elements.length - 1) - const el = elements[nextIndex] - if (el) focusElement(el) - return - } + if (direction === 'next') { + const nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, elements.length - 1) + const el = elements[nextIndex] + if (el) focusElement(el) + return + } - if (e.key === 'ArrowUp') { - e.preventDefault() - // At first result or no result focused: return focus to search input - if (currentIndex <= 0) { - focusSearchInput() + if (direction === 'previous') { + // At first result or no result focused: return focus to search input + if (currentIndex <= 0) { + focusSearchInput() + return + } + const nextIndex = currentIndex - 1 + const el = elements[nextIndex] + if (el) focusElement(el) return } - const nextIndex = currentIndex - 1 - const el = elements[nextIndex] - if (el) focusElement(el) - return } if (e.key === 'Enter') { // Browser handles Enter on focused links naturally, but handle for non-link elements - if (document.activeElement && elements.includes(document.activeElement as HTMLElement)) { + if ( + document.activeElement && + getFocusableElements().includes(document.activeElement as HTMLElement) + ) { const el = document.activeElement as HTMLElement - // Only prevent default and click if it's not already a link (links handle Enter natively) if (el.tagName !== 'A') { e.preventDefault() el.click() @@ -555,7 +562,7 @@ function handleResultsKeydown(e: KeyboardEvent) { } } -onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown) +onKeyDown(['j', 'k', 'Enter'], handleResultsKeydown) useSeoMeta({ title: () => diff --git a/app/utils/search-navigation.ts b/app/utils/search-navigation.ts new file mode 100644 index 0000000000..0638bf79ab --- /dev/null +++ b/app/utils/search-navigation.ts @@ -0,0 +1,20 @@ +export type SearchResultNavigationDirection = 'next' | 'previous' + +export function getSearchResultNavigationDirection( + key: string, +): SearchResultNavigationDirection | null { + switch (key) { + case 'j': + case 'J': + return 'next' + case 'k': + case 'K': + return 'previous' + default: + return null + } +} + +export function isSearchResultNavigationKey(key: string): boolean { + return getSearchResultNavigationDirection(key) !== null || key === 'Enter' +} diff --git a/test/unit/app/utils/search-navigation.spec.ts b/test/unit/app/utils/search-navigation.spec.ts new file mode 100644 index 0000000000..c11494e22b --- /dev/null +++ b/test/unit/app/utils/search-navigation.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + getSearchResultNavigationDirection, + isSearchResultNavigationKey, +} from '../../../../app/utils/search-navigation' + +describe('search navigation helper', () => { + it('returns next for j and J', () => { + expect(getSearchResultNavigationDirection('j')).toBe('next') + expect(getSearchResultNavigationDirection('J')).toBe('next') + }) + + it('returns previous for k and K', () => { + expect(getSearchResultNavigationDirection('k')).toBe('previous') + expect(getSearchResultNavigationDirection('K')).toBe('previous') + }) + + it('returns null for non-navigation keys', () => { + expect(getSearchResultNavigationDirection('ArrowDown')).toBeNull() + expect(getSearchResultNavigationDirection('Enter')).toBeNull() + expect(getSearchResultNavigationDirection('x')).toBeNull() + }) + + it('identifies j/k/Enter as navigation keys', () => { + expect(isSearchResultNavigationKey('j')).toBe(true) + expect(isSearchResultNavigationKey('k')).toBe(true) + expect(isSearchResultNavigationKey('Enter')).toBe(true) + }) + + it('does not identify other keys as navigation keys', () => { + expect(isSearchResultNavigationKey('ArrowDown')).toBe(false) + expect(isSearchResultNavigationKey('Escape')).toBe(false) + expect(isSearchResultNavigationKey(' ')).toBe(false) + }) +})