Skip to content

Commit 9439b16

Browse files
committed
fix(ui): arrow key navigation for org page
- adding a composable to handle arrows on org page - shared composable back with search results page fixes #2338
1 parent 516808b commit 9439b16

File tree

6 files changed

+260
-79
lines changed

6 files changed

+260
-79
lines changed

app/components/BaseCard.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ defineProps<{
33
/** Whether this is an exact match for the query */
44
isExactMatch?: boolean
55
selected?: boolean
6+
/** Index for keyboard navigation */
7+
index?: number
68
}>()
79
</script>
810

911
<template>
1012
<article
11-
class="group bg-bg-subtle border border-border rounded-lg p-4 sm:p-6 transition-[border-color,background-color] duration-200 hover:(border-border-hover bg-bg-muted) cursor-pointer relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 focus-within:bg-bg-muted focus-within:border-border-hover"
13+
tabindex="0"
14+
:data-result-index="index"
15+
class="group bg-bg-subtle border border-border rounded-lg p-4 sm:p-6 transition-[border-color,background-color] duration-200 hover:(border-border-hover bg-bg-muted) cursor-pointer relative focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-bg focus-visible:ring-offset-2 focus-visible:ring-accent focus-visible:bg-bg-muted focus-visible:border-accent/50"
1216
:class="{
1317
'border-accent/30 contrast-more:border-accent/90 bg-accent/5': isExactMatch,
1418
'bg-fg-subtle/15!': selected,

app/components/Package/Card.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const numberFormatter = useNumberFormatter()
4343
</script>
4444

4545
<template>
46-
<BaseCard :selected="isSelected" :isExactMatch="isExactMatch">
46+
<BaseCard :selected="isSelected" :isExactMatch="isExactMatch" :index="index">
4747
<header class="mb-4 flex items-baseline justify-between gap-2">
4848
<component
4949
:is="headingLevel ?? 'h3'"
@@ -53,7 +53,6 @@ const numberFormatter = useNumberFormatter()
5353
:to="packageRoute(result.package.name)"
5454
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
5555
class="decoration-none after:content-[''] after:absolute after:inset-0"
56-
:data-result-index="index"
5756
dir="ltr"
5857
>{{ result.package.name }}</NuxtLink
5958
>
@@ -153,7 +152,6 @@ const numberFormatter = useNumberFormatter()
153152
</div>
154153

155154
<ul
156-
role="list"
157155
v-if="result.package.keywords?.length"
158156
:aria-label="$t('package.card.keywords')"
159157
class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0 pointer-events-none items-center"
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useEventListener } from '@vueuse/core'
2+
3+
/**
4+
* Composable for keyboard navigation through search results and package lists
5+
*
6+
* Provides arrow key navigation (ArrowUp/ArrowDown) and Enter key support
7+
* for navigating through focusable result elements.
8+
*
9+
* @param options - Configuration options
10+
* @param options.includeSuggestions - Whether to include suggestion elements (data-suggestion-index)
11+
* @param options.onArrowUpAtStart - Optional callback when ArrowUp is pressed at the first element
12+
*/
13+
export function useResultsKeyboardNavigation(options?: {
14+
includeSuggestions?: boolean
15+
onArrowUpAtStart?: () => void
16+
}) {
17+
const keyboardShortcuts = useKeyboardShortcuts()
18+
19+
const isVisible = (el: HTMLElement) => el.getClientRects().length > 0
20+
21+
/**
22+
* Get all focusable result elements in DOM order
23+
*/
24+
function getFocusableElements(): HTMLElement[] {
25+
const elements: HTMLElement[] = []
26+
27+
// Include suggestions if enabled (used on search page)
28+
if (options?.includeSuggestions) {
29+
const suggestions = Array.from(
30+
document.querySelectorAll<HTMLElement>('[data-suggestion-index]'),
31+
)
32+
.filter(isVisible)
33+
.sort((a, b) => {
34+
const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10)
35+
const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10)
36+
return aIdx - bIdx
37+
})
38+
elements.push(...suggestions)
39+
}
40+
41+
// Always include package results
42+
const packages = Array.from(document.querySelectorAll<HTMLElement>('[data-result-index]'))
43+
.filter(isVisible)
44+
.sort((a, b) => {
45+
const aIdx = Number.parseInt(a.dataset.resultIndex ?? '0', 10)
46+
const bIdx = Number.parseInt(b.dataset.resultIndex ?? '0', 10)
47+
return aIdx - bIdx
48+
})
49+
elements.push(...packages)
50+
51+
return elements
52+
}
53+
54+
/**
55+
* Focus an element and scroll it into view if needed
56+
*/
57+
function focusElement(el: HTMLElement) {
58+
el.focus({ preventScroll: true })
59+
60+
// Only scroll if element is not already in viewport
61+
const rect = el.getBoundingClientRect()
62+
const isInViewport = (
63+
rect.top >= 0 &&
64+
rect.left >= 0 &&
65+
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
66+
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
67+
)
68+
69+
if (!isInViewport) {
70+
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
71+
}
72+
}
73+
74+
function handleKeydown(e: KeyboardEvent) {
75+
// Only handle arrow keys and Enter
76+
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
77+
return
78+
}
79+
80+
if (!keyboardShortcuts.value) {
81+
return
82+
}
83+
84+
const elements = getFocusableElements()
85+
const currentIndex = elements.findIndex(el => el === document.activeElement)
86+
87+
if (e.key === 'ArrowDown') {
88+
// If there are results available, handle navigation
89+
if (elements.length > 0) {
90+
e.preventDefault()
91+
e.stopPropagation()
92+
93+
// If no result is focused, focus the first one
94+
if (currentIndex < 0) {
95+
const firstEl = elements[0]
96+
if (firstEl) focusElement(firstEl)
97+
return
98+
}
99+
100+
// If a result is already focused, move to the next one
101+
const nextIndex = Math.min(currentIndex + 1, elements.length - 1)
102+
const el = elements[nextIndex]
103+
if (el) focusElement(el)
104+
}
105+
return
106+
}
107+
108+
if (e.key === 'ArrowUp') {
109+
// Only intercept if a result is already focused
110+
if (currentIndex >= 0) {
111+
e.preventDefault()
112+
e.stopPropagation()
113+
114+
// At first result
115+
if (currentIndex === 0) {
116+
// Call custom callback if provided (e.g., return focus to search input)
117+
if (options?.onArrowUpAtStart) {
118+
options.onArrowUpAtStart()
119+
}
120+
return
121+
}
122+
const nextIndex = currentIndex - 1
123+
const el = elements[nextIndex]
124+
if (el) focusElement(el)
125+
}
126+
return
127+
}
128+
129+
if (e.key === 'Enter') {
130+
// Handle Enter on focused card - click the main link inside
131+
if (document.activeElement && elements.includes(document.activeElement as HTMLElement)) {
132+
const card = document.activeElement as HTMLElement
133+
// Find the first link inside the card and click it
134+
const link = card.querySelector('a')
135+
if (link) {
136+
e.preventDefault()
137+
e.stopPropagation()
138+
link.click()
139+
}
140+
}
141+
}
142+
}
143+
144+
// Register keyboard event listeners using useEventListener for better control
145+
// Use capture phase to intercept before other handlers
146+
useEventListener(document, 'keydown', handleKeydown, { capture: true })
147+
148+
return {
149+
getFocusableElements,
150+
focusElement,
151+
}
152+
}

app/pages/org/[org].vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ function handleClearFilter(chip: FilterChip) {
123123
124124
const activeTab = shallowRef<'members' | 'teams'>('members')
125125
126+
// Keyboard navigation for package results
127+
useResultsKeyboardNavigation()
128+
126129
// Canonical URL for this org page
127130
const canonicalUrl = computed(() => `https://npmx.dev/@${orgName.value}`)
128131

app/pages/search.vue

Lines changed: 9 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -408,39 +408,6 @@ const exactMatchType = computed<'package' | 'org' | 'user' | null>(() => {
408408
const suggestionCount = computed(() => validatedSuggestions.value.length)
409409
const totalSelectableCount = computed(() => suggestionCount.value + resultCount.value)
410410
411-
const isVisible = (el: HTMLElement) => el.getClientRects().length > 0
412-
413-
/**
414-
* Get all focusable result elements in DOM order (suggestions first, then packages)
415-
*/
416-
function getFocusableElements(): HTMLElement[] {
417-
const suggestions = Array.from(document.querySelectorAll<HTMLElement>('[data-suggestion-index]'))
418-
.filter(isVisible)
419-
.sort((a, b) => {
420-
const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10)
421-
const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10)
422-
return aIdx - bIdx
423-
})
424-
425-
const packages = Array.from(document.querySelectorAll<HTMLElement>('[data-result-index]'))
426-
.filter(isVisible)
427-
.sort((a, b) => {
428-
const aIdx = Number.parseInt(a.dataset.resultIndex ?? '0', 10)
429-
const bIdx = Number.parseInt(b.dataset.resultIndex ?? '0', 10)
430-
return aIdx - bIdx
431-
})
432-
433-
return [...suggestions, ...packages]
434-
}
435-
436-
/**
437-
* Focus an element and scroll it into view
438-
*/
439-
function focusElement(el: HTMLElement) {
440-
el.focus()
441-
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
442-
}
443-
444411
// Navigate to package page
445412
async function navigateToPackage(packageName: string) {
446413
await navigateTo(packageRoute(packageName))
@@ -482,9 +449,16 @@ function focusSearchInput() {
482449
searchInput?.focus()
483450
}
484451
452+
// Keyboard navigation for results (includes arrow keys and Enter on focused results)
453+
useResultsKeyboardNavigation({
454+
includeSuggestions: true,
455+
onArrowUpAtStart: focusSearchInput,
456+
})
457+
458+
// Additional Enter key handling for search input (exact match navigation)
485459
const keyboardShortcuts = useKeyboardShortcuts()
486460
487-
function handleResultsKeydown(e: KeyboardEvent) {
461+
function handleSearchInputEnter(e: KeyboardEvent) {
488462
if (!keyboardShortcuts.value) {
489463
return
490464
}
@@ -508,49 +482,9 @@ function handleResultsKeydown(e: KeyboardEvent) {
508482
pendingEnterQuery.value = inputValue
509483
return
510484
}
511-
512-
if (totalSelectableCount.value <= 0) return
513-
514-
const elements = getFocusableElements()
515-
if (elements.length === 0) return
516-
517-
const currentIndex = elements.findIndex(el => el === document.activeElement)
518-
519-
if (e.key === 'ArrowDown') {
520-
e.preventDefault()
521-
const nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, elements.length - 1)
522-
const el = elements[nextIndex]
523-
if (el) focusElement(el)
524-
return
525-
}
526-
527-
if (e.key === 'ArrowUp') {
528-
e.preventDefault()
529-
// At first result or no result focused: return focus to search input
530-
if (currentIndex <= 0) {
531-
focusSearchInput()
532-
return
533-
}
534-
const nextIndex = currentIndex - 1
535-
const el = elements[nextIndex]
536-
if (el) focusElement(el)
537-
return
538-
}
539-
540-
if (e.key === 'Enter') {
541-
// Browser handles Enter on focused links naturally, but handle for non-link elements
542-
if (document.activeElement && elements.includes(document.activeElement as HTMLElement)) {
543-
const el = document.activeElement as HTMLElement
544-
// Only prevent default and click if it's not already a link (links handle Enter natively)
545-
if (el.tagName !== 'A') {
546-
e.preventDefault()
547-
el.click()
548-
}
549-
}
550-
}
551485
}
552486
553-
onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown)
487+
onKeyDown('Enter', handleSearchInputEnter)
554488
555489
useSeoMeta({
556490
title: () =>

0 commit comments

Comments
 (0)