fix(ui): arrow key navigation for org page#2339
fix(ui): arrow key navigation for org page#2339tylersayshi wants to merge 8 commits intonpmx-dev:mainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
|
Hello! Thank you for opening your first PR to npmx, @tylersayshi! 🚀 Here’s what will happen next:
|
|
also, great meeting y'all at https://atmosphereconf.org - thanks for npmx ❤️ |
- adding a composable to handle arrows on org page - shared composable back with search results page fixes npmx-dev#2338
cc67c76 to
9439b16
Compare
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a composable, Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/pages/search.vue (1)
461-487:⚠️ Potential issue | 🟡 MinorScope Enter handling to the actual search input.
The Enter handler currently matches any
<input>element on the page. Please check the header search box explicitly before reading its value to prevent pressing Enter in other focused inputs from rewritingcommittedQuerywith unintended data.Suggested approach
function handleSearchInputEnter(e: KeyboardEvent) { if (!keyboardShortcuts.value) { return } + const activeElement = document.activeElement // If the active element is an input, navigate to exact match or wait for results - if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') { + if ( + e.key === 'Enter' && + activeElement instanceof HTMLInputElement && + activeElement.matches('#header-search, input[name="q"]') + ) { // Get value directly from input (not from route query, which may be debounced) - const inputValue = (document.activeElement as HTMLInputElement).value.trim() + const inputValue = activeElement.value.trim()
🧹 Nitpick comments (1)
test/e2e/interactions.spec.ts (1)
313-362: Add one regression case for focused controls.The new handlers are document-scoped, but these tests only drive the page from neutral focus. A small case that presses ArrowDown on the org sort control and Enter on a toolbar input on the search page would catch the two easiest regressions here.
Also applies to: 411-445
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f006bc64-b295-4607-b776-f0360ae50e9a
📒 Files selected for processing (6)
app/components/BaseCard.vueapp/components/Package/Card.vueapp/composables/useResultsKeyboardNavigation.tsapp/pages/org/[org].vueapp/pages/search.vuetest/e2e/interactions.spec.ts
| 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 }) |
There was a problem hiding this comment.
Don't steal ArrowUp/ArrowDown from native controls.
This handler runs at document scope and calls preventDefault() before the page's own controls can react. On both app/pages/org/[org].vue:267-288 and app/pages/search.vue:700-724, pressing ArrowDown inside the toolbar's selects or text inputs will jump to the first result instead of moving the caret or opening the control. Please ignore interactive targets unless focus is already on a result element or on an explicitly allowed hand-off source.
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
+ }There was a problem hiding this comment.
I believe stealing the arrow keys is desired in this instance, but anyone please tell me if I'm wrong here 👍
There was a problem hiding this comment.
The Comment image shows an organization page at localhost:3000/org/atcute in a npm-like UI. Visible elements include:
- Top bar with a search input labeled “/ search packages …”, and actions: compare, settings, connect.
- Org header with avatar “A”, handle “@ATCUTE”, “58 public packages”, and an “npm” badge with “58,784 / week”.
- A PACKAGES section: “58 packages”, a “Filters” button, a sort control showing “Downloads/wk” (native select), and list/grid view toggles.
- A list of package cards (e.g.,
@atcute/time-ms,@atcute/bluesky) each with description, date, version, and weekly downloads.
@tylersayshi I love the consistency goal, but stealing ArrowUp/ArrowDown from native controls (inputs/selects/textarea/contenteditable) at the document level will block expected keyboard behavior (caret movement, opening selects, adjusting ranges) and hurts accessibility. Right now, ArrowDown is intercepted even when focus is inside the sort select or the search box, because we call preventDefault in capture before those controls can react.
A small compromise keeps the desired list navigation while preserving native control behavior:
- Only start navigation when:
- focus is already on a result element, or
- the current target is explicitly whitelisted (e.g., the search input on /search).
- Otherwise, let native controls handle Arrow keys.
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
- Search page: allow starting from the search input only (so ArrowDown in the box moves to first result as today).
useResultsKeyboardNavigation({
includeSuggestions: true,
onArrowUpAtStart: focusSearchInput,
canStartFrom: (el) => !!el?.closest('input[type="search"], [data-search-input="true"]'),
})- Org page: don’t pass canStartFrom so Arrow keys inside “Downloads/wk” select, Filters inputs, etc., keep their native behavior; navigation still works once focus is on a result card.
Optional guard
- Also ignore modified keys to avoid surprises:
if (e.altKey || e.ctrlKey || e.metaKey) returnTests to lock behavior
- Add e2e asserting:
- ArrowDown inside the sort does not move focus to the first result on /org/atcute. ArrowDown in the search input on /search starts navigation (whitelisted). ArrowDown on the document body starts at first result when nothing interactive is focused. If you’d prefer truly global “steal-from-anywhere,” I can roll with that, but it will block selects and text inputs across the page. The above tweak preserves your intended UX on /search while keeping native controls functional on the org page. Want me to prep a patch? 🧠 Learnings used Learnt from: btea Repo: npmx-dev/npmx.dev PR: 1739 File: app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue:259-262 Timestamp: 2026-02-28T07:17:28.172Z Learning: In app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue, the scroll-to-top button for the code preview container should be shown on both desktop and touch devices (gated only by scroll position using SCROLL_TO_TOP_THRESHOLD), not restricted to touch devices. This is different from the window-level ScrollToTop.client.vue component which is touch-device-only, because the code container scroll-to-top helps users navigate long code files where keyboard shortcuts are less practical for container-specific scrolling. Learnt from: graphieros Repo: npmx-dev/npmx.dev PR: 2273 File: app/components/Package/TrendsChart.vue:1648-1648 Timestamp: 2026-03-25T12:31:33.715Z Learning: In `app/components/Package/TrendsChart.vue`, the `tabindex` values on the view-toggle tab buttons (combined/split chart layout) are intentionally inverted from the standard WAI-ARIA pattern. The **selected** tab receives `tabindex="-1"` and the **unselected** tab receives `tabindex="0"`. Do not flag this as a bug or suggest reversing the values to match the WAI-ARIA tabs pattern. Learnt from: userquin Repo: npmx-dev/npmx.dev PR: 1596 File: app/pages/about.vue:318-341 Timestamp: 2026-02-23T05:31:58.922Z Learning: In the npmx.dev project, the contributor popover on the about page is intentionally a mouse-only progressive enhancement. Keyboard support (focus/blur with full popover interaction) was tested but rejected due to Firefox performance issues (3-4 second lag with per-contributor popovers in the Top Layer) and implementation complexity (aria-controls, aria-expanded, focus trapping, Escape handling). All contributor information remains accessible via the clickable link to the GitHub profile. Learnt from: ShroXd Repo: npmx-dev/npmx.dev PR: 2115 File: app/pages/search.vue:365-368 Timestamp: 2026-03-17T06:53:49.002Z Learning: In npmx-dev/npmx.dev (app/pages/search.vue), the `showClaimPrompt` computed intentionally returns `true` during a `status === 'pending'` fetch even when `avail.name` doesn't match `committedQuery.value.trim()`. This is by design to prevent the claim prompt from flickering/disappearing while the user is typing (avoiding layout shifts). The claim button is separately guarded with `:disabled="status === 'pending'"` to prevent accidental clicks on stale data. Do not suggest adding an `avail.name === committedQuery` guard to the pending branch. Learnt from: danielroe Repo: npmx-dev/npmx.dev PR: 1204 File: app/composables/npm/useUserPackages.ts:40-41 Timestamp: 2026-02-08T13:24:06.104Z Learning: In Nuxt 3, when using useAsyncData or useLazyAsyncData, allow and rely on reactive keys by typing them as MaybeRefOrGetter<string>. Keys can be a string, a Ref<string>, a ComputedRef<string>, or a getter () => string. Reactive keys should automatically trigger refetches when their value changes. In reviews, verify that data scripts pass a MaybeRefOrGetter<string> (not a plain string-only key if reactivity is intended), and that the logic handles dynamic key changes without stale data. Learnt from: MatteoGabriele Repo: npmx-dev/npmx.dev PR: 1922 File: app/composables/usePackageListPreferences.ts:52-53 Timestamp: 2026-03-05T10:14:50.799Z Learning: In Nuxt projects (here npmx-dev/npmx.dev), exports from shared/types/* (e.g., PAGE_SIZE_OPTIONS, DEFAULT_COLUMNS, DEFAULT_PREFERENCES, PageSize) are auto-imported by Nuxt for composables and components. Do not add explicit import statements for these constants/types when using files under shared/types/, and rely on the auto-imported bindings in files under app/composables (and similarly in components). This pattern applies to all TS files within app/composables that reference these shared/types exports.
ac52d75 to
848a1ad
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
app/composables/useResultsKeyboardNavigation.ts (1)
75-93:⚠️ Potential issue | 🟠 MajorArrowDown still hijacks native control navigation
Line 75 intercepts ArrowDown globally when results exist, so focused inputs/selects can lose native arrow-key behaviour. This concern was already raised and appears unresolved in this revision.
Suggested adjustment
export function useResultsKeyboardNavigation(options?: { includeSuggestions?: boolean onArrowUpAtStart?: () => void + canStartFrom?: (target: HTMLElement | null) => boolean }) { @@ if (e.key === 'ArrowDown') { + 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 + } + // If there are results available, handle navigation if (elements.length > 0) {
🧹 Nitpick comments (1)
app/composables/useResultsKeyboardNavigation.ts (1)
62-130: SplithandleKeydowninto per-key handlers for maintainability
handleKeydownis doing three flows and is now quite long; extractinghandleArrowDown,handleArrowUp, andhandleEnterwould keep this composable easier to test and evolve.As per coding guidelines, "Keep functions focused and manageable (generally under 50 lines)".
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: fc18951c-d3d0-44f6-9c25-ea714f5f3c46
📒 Files selected for processing (1)
app/composables/useResultsKeyboardNavigation.ts
|
|
||
| // Register keyboard event listeners using useEventListener for better control | ||
| // Use capture phase to intercept before other handlers | ||
| useEventListener(document, 'keydown', handleKeydown, { capture: true }) |
There was a problem hiding this comment.
🧩 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 document access for SSR-safe composable execution
This composable is called during page setup in both app/pages/search.vue and app/pages/org/[org].vue, which are SSR-enabled. Direct document access on lines 30, 42, 73, 119, 120, and 134 will throw document is not defined during server-side rendering.
SSR-safe patch
export function useResultsKeyboardNavigation(options?: {
includeSuggestions?: boolean
onArrowUpAtStart?: () => void
}) {
+ if (import.meta.server) {
+ return {
+ getFocusableElements: (): HTMLElement[] => [],
+ focusElement: (_el: HTMLElement) => {},
+ }
+ }
+
const keyboardShortcuts = useKeyboardShortcuts()
🔗 Linked issue
resolves #2338
🧭 Context
This adds the key control for the org page like on https://npmx.dev/org/atcute
📚 Description