-
-
Notifications
You must be signed in to change notification settings - Fork 408
fix(ui): arrow key navigation for org page #2339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9439b16
2e616f6
59e5809
bf648bd
176e126
848a1ad
1374d9f
ab5cc25
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import { useEventListener } from '@vueuse/core' | ||
|
|
||
| /** | ||
| * Composable for keyboard navigation through search results and package lists | ||
| * | ||
| * Provides arrow key navigation (ArrowUp/ArrowDown) and Enter key support | ||
| * for navigating through focusable result elements. | ||
| * | ||
| * @param options - Configuration options | ||
| * @param options.includeSuggestions - Whether to include suggestion elements (data-suggestion-index) | ||
| * @param options.onArrowUpAtStart - Optional callback when ArrowUp is pressed at the first element | ||
| */ | ||
| export function useResultsKeyboardNavigation(options?: { | ||
|
Check warning on line 13 in app/composables/useResultsKeyboardNavigation.ts
|
||
| includeSuggestions?: boolean | ||
| onArrowUpAtStart?: () => void | ||
| }) { | ||
| const keyboardShortcuts = useKeyboardShortcuts() | ||
|
|
||
| const isVisible = (el: HTMLElement) => el.getClientRects().length > 0 | ||
|
|
||
| /** | ||
| * Get all focusable result elements in DOM order | ||
| */ | ||
| function getFocusableElements(): HTMLElement[] { | ||
| const elements: HTMLElement[] = [] | ||
|
|
||
| // Include suggestions if enabled (used on search page) | ||
| if (options?.includeSuggestions) { | ||
| const suggestions = Array.from( | ||
| document.querySelectorAll<HTMLElement>('[data-suggestion-index]'), | ||
| ) | ||
| .filter(isVisible) | ||
| .sort((a, b) => { | ||
| const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10) | ||
| const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10) | ||
| return aIdx - bIdx | ||
| }) | ||
| elements.push(...suggestions) | ||
| } | ||
|
|
||
| // Always include package results | ||
| const packages = Array.from(document.querySelectorAll<HTMLElement>('[data-result-index]')) | ||
| .filter(isVisible) | ||
| .sort((a, b) => { | ||
| const aIdx = Number.parseInt(a.dataset.resultIndex ?? '0', 10) | ||
| const bIdx = Number.parseInt(b.dataset.resultIndex ?? '0', 10) | ||
| return aIdx - bIdx | ||
| }) | ||
| elements.push(...packages) | ||
|
|
||
| return elements | ||
| } | ||
|
|
||
| /** | ||
| * Focus an element and scroll it into view if needed | ||
| */ | ||
| function focusElement(el: HTMLElement) { | ||
| el.focus({ preventScroll: true }) | ||
| el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) | ||
| } | ||
|
|
||
| function handleKeydown(e: KeyboardEvent) { | ||
| // Only handle arrow keys and Enter | ||
| if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) { | ||
| return | ||
| } | ||
|
|
||
| if (!keyboardShortcuts.value) { | ||
| return | ||
| } | ||
|
|
||
| const elements = getFocusableElements() | ||
| const currentIndex = elements.findIndex(el => el === document.activeElement) | ||
|
|
||
| if (e.key === 'ArrowDown') { | ||
| // If there are results available, handle navigation | ||
| if (elements.length > 0) { | ||
| e.preventDefault() | ||
| e.stopPropagation() | ||
|
|
||
| // If no result is focused, focus the first one | ||
| if (currentIndex < 0) { | ||
| const firstEl = elements[0] | ||
| if (firstEl) focusElement(firstEl) | ||
| return | ||
| } | ||
|
|
||
| // If a result is already focused, move to the next one | ||
| const nextIndex = Math.min(currentIndex + 1, elements.length - 1) | ||
| const el = elements[nextIndex] | ||
| if (el) focusElement(el) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| if (e.key === 'ArrowUp') { | ||
| // Only intercept if a result is already focused | ||
| if (currentIndex >= 0) { | ||
| e.preventDefault() | ||
| e.stopPropagation() | ||
|
|
||
| // At first result | ||
| if (currentIndex === 0) { | ||
| // Call custom callback if provided (e.g., return focus to search input) | ||
| if (options?.onArrowUpAtStart) { | ||
| options.onArrowUpAtStart() | ||
| } | ||
| return | ||
| } | ||
| const nextIndex = currentIndex - 1 | ||
| const el = elements[nextIndex] | ||
| if (el) focusElement(el) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| if (e.key === 'Enter') { | ||
| // Handle Enter on focused card - click the main link inside | ||
| if (document.activeElement && elements.includes(document.activeElement as HTMLElement)) { | ||
| const card = document.activeElement as HTMLElement | ||
| // Find the first link inside the card and click it | ||
| const link = card.querySelector('a') | ||
| if (link) { | ||
| e.preventDefault() | ||
| e.stopPropagation() | ||
| link.click() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Register keyboard event listeners using useEventListener for better control | ||
| // Use capture phase to intercept before other handlers | ||
| useEventListener(document, 'keydown', handleKeydown, { capture: true }) | ||
|
Comment on lines
+62
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't steal ArrowUp/ArrowDown from native controls. This handler runs at document scope and calls Possible direction export function useResultsKeyboardNavigation(options?: {
includeSuggestions?: boolean
onArrowUpAtStart?: () => void
+ canStartFrom?: (target: HTMLElement | null) => boolean
}) {
@@
const elements = getFocusableElements()
const currentIndex = elements.findIndex(el => el === document.activeElement)
+ const target = e.target instanceof HTMLElement ? e.target : null
+ const isInteractiveTarget = !!target?.closest(
+ 'input, textarea, select, button, [contenteditable="true"]',
+ )
+
+ if (currentIndex < 0 && isInteractiveTarget && !options?.canStartFrom?.(target)) {
+ return
+ }
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe stealing the arrow keys is desired in this instance, but anyone please tell me if I'm wrong here 👍
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Comment image shows an organization page at localhost:3000/org/atcute in a npm-like UI. Visible elements include:
A small compromise keeps the desired list navigation while preserving native control behavior:
Minimal patch to the composable // app/composables/useResultsKeyboardNavigation.ts
export function useResultsKeyboardNavigation(options?: {
includeSuggestions?: boolean
onArrowUpAtStart?: () => void
canStartFrom?: (target: HTMLElement | null) => boolean
}) {
// ...
function handleKeydown(e: KeyboardEvent) {
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key) || !keyboardShortcuts.value) return
const elements = getFocusableElements()
const currentIndex = elements.findIndex(el => el === document.activeElement)
const target = e.target instanceof HTMLElement ? e.target : null
const isInteractiveTarget = !!target?.closest('input, textarea, select, button, [contenteditable="true"]')
// Don’t hijack Arrow keys from interactive widgets unless navigation is already active
// or the caller explicitly allows starting from this target.
if (currentIndex < 0 && isInteractiveTarget && !options?.canStartFrom?.(target)) {
return
}
// ...existing ArrowDown/ArrowUp/Enter logic unchanged...
}
useEventListener(document, 'keydown', handleKeydown, { capture: true })
}Call-site usage
useResultsKeyboardNavigation({
includeSuggestions: true,
onArrowUpAtStart: focusSearchInput,
canStartFrom: (el) => !!el?.closest('input[type="search"], [data-search-input="true"]'),
})
Optional guard
if (e.altKey || e.ctrlKey || e.metaKey) returnTests to lock behavior
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "1) Locate all call sites:"
rg -n -C2 '\buseResultsKeyboardNavigation\s*\(' --type=vue --type=ts
echo
echo "2) Check whether call sites are in client-only files or guarded blocks:"
rg -n -C3 'useResultsKeyboardNavigation|import\.meta\.client' app/pages app/components app/composables
echo
echo "3) Confirm direct document usage in the composable:"
rg -n -C2 '\bdocument\b' app/composables/useResultsKeyboardNavigation.tsRepository: npmx-dev/npmx.dev Length of output: 131 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "1) Locate all call sites of useResultsKeyboardNavigation:"
rg -n 'useResultsKeyboardNavigation' app/pages app/components --type ts || echo "No matches found in pages/components"
echo
echo "2) Check composable file for .client suffix and document usage:"
ls -la app/composables/useResultsKeyboardNavigation* 2>/dev/null || echo "Checking exact filename..."
rg -n '\bdocument\b' app/composables/useResultsKeyboardNavigation.ts || echo "No document usage found"
echo
echo "3) Check for import.meta.client or import.meta.server guards in related composables:"
rg -n 'import\.meta\.(client|server)' app/composables/ --type ts -A2 -B2 | head -50
echo
echo "4) Verify the composable signature and return type:"
rg -n -A20 'export function useResultsKeyboardNavigation' app/composables/useResultsKeyboardNavigation.tsRepository: npmx-dev/npmx.dev Length of output: 4573 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "1) Search entire repository for useResultsKeyboardNavigation:"
rg -n 'useResultsKeyboardNavigation' --type ts --type js || echo "No matches in codebase"
echo
echo "2) Check if exported from any barrel files (index.ts/index.js):"
find app -name 'index.ts' -o -name 'index.js' | xargs grep -l 'useResultsKeyboardNavigation' 2>/dev/null || echo "Not in barrel exports"
echo
echo "3) Check entire composable for return statement (to see if it returns early on SSR):"
cat -n app/composables/useResultsKeyboardNavigation.ts | tail -20
echo
echo "4) Look for any import.meta checks at start of composable:"
head -30 app/composables/useResultsKeyboardNavigation.ts | cat -nRepository: npmx-dev/npmx.dev Length of output: 2570 🏁 Script executed: #!/bin/bash
set -euo pipefill
echo "1) Check page filenames (are they .client.vue?):"
ls -la app/pages/search.vue app/pages/org/\[org\].vue
echo
echo "2) Check context around useResultsKeyboardNavigation calls:"
echo "--- search.vue around line 448 ---"
sed -n '440,460p' app/pages/search.vue | cat -n
echo
echo "--- org/[org].vue around line 127 ---"
sed -n '120,135p' app/pages/org/\[org\].vue | cat -n
echo
echo "3) Check if pages have any import.meta.client guards:"
rg -n 'import\.meta\.(client|server)' app/pages/search.vue app/pages/org/\[org\].vueRepository: npmx-dev/npmx.dev Length of output: 114 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "1) Check page filenames (are they .client.vue?):"
ls -la app/pages/search.vue app/pages/org/\[org\].vue
echo
echo "2) Check context around useResultsKeyboardNavigation calls:"
echo "--- search.vue around line 448 ---"
sed -n '440,460p' app/pages/search.vue | cat -n
echo
echo "--- org/[org].vue around line 127 ---"
sed -n '120,135p' app/pages/org/\[org\].vue | cat -n
echo
echo "3) Check if pages have any import.meta.client guards:"
rg -n 'import\.meta\.(client|server)' app/pages/search.vue app/pages/org/\[org\].vue || echo "No guards found"Repository: npmx-dev/npmx.dev Length of output: 1768 Guard This composable is called during page setup in both SSR-safe patch export function useResultsKeyboardNavigation(options?: {
includeSuggestions?: boolean
onArrowUpAtStart?: () => void
}) {
+ if (import.meta.server) {
+ return {
+ getFocusableElements: (): HTMLElement[] => [],
+ focusElement: (_el: HTMLElement) => {},
+ }
+ }
+
const keyboardShortcuts = useKeyboardShortcuts() |
||
|
|
||
| return { | ||
| getFocusableElements, | ||
| focusElement, | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.