Skip to content

fix(ui): arrow key navigation for org page#2339

Open
tylersayshi wants to merge 6 commits intonpmx-dev:mainfrom
tylersayshi:tyler-fix-org-keys
Open

fix(ui): arrow key navigation for org page#2339
tylersayshi wants to merge 6 commits intonpmx-dev:mainfrom
tylersayshi:tyler-fix-org-keys

Conversation

@tylersayshi
Copy link
Copy Markdown

@tylersayshi tylersayshi commented Mar 31, 2026

🔗 Linked issue

resolves #2338

🧭 Context

This adds the key control for the org page like on https://npmx.dev/org/atcute

📚 Description

  • adding a composable to handle arrows on org page
  • shared composable back with search results page
  • added tests for checking interactions on the org page for this behavior

Kapture 2026-03-30 at 17 47 48

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 31, 2026 1:58am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 31, 2026 1:58am
npmx-lunaria Ignored Ignored Mar 31, 2026 1:58am

Request Review

@github-actions
Copy link
Copy Markdown

Hello! Thank you for opening your first PR to npmx, @tylersayshi! 🚀

Here’s what will happen next:

  1. Our GitHub bots will run to check your changes.
    If they spot any issues you will see some error messages on this PR.
    Don’t hesitate to ask any questions if you’re not sure what these mean!

  2. In a few minutes, you’ll be able to see a preview of your changes on Vercel

  3. One or more of our maintainers will take a look and may ask you to make changes.
    We try to be responsive, but don’t worry if this takes a few days.

@tylersayshi
Copy link
Copy Markdown
Author

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
@tylersayshi tylersayshi changed the title fix(org-page): arrow key navigation for org page fix(ui): arrow key navigation for org page Mar 31, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2ebee34d-e587-4802-bce5-1b013105e933

📥 Commits

Reviewing files that changed from the base of the PR and between bf648bd and 848a1ad.

📒 Files selected for processing (2)
  • app/components/Package/List.vue
  • test/fixtures/npm-registry/orgs/atcute.json
✅ Files skipped from review due to trivial changes (1)
  • test/fixtures/npm-registry/orgs/atcute.json

📝 Walkthrough

Walkthrough

Adds keyboard navigation for result lists via a new composable useResultsKeyboardNavigation that listens for ArrowUp/ArrowDown/Enter and moves focus among elements annotated with data-result-index (and optionally data-suggestion-index). BaseCard receives an optional index prop, exposes data-result-index on its root, and switches focus styling from focus-within to focus-visible. The composable is used on the org results page and replaces prior keyboard handling on the search page. New e2e tests cover the behaviour and the keyboard-shortcuts-disabled cases.

Suggested reviewers

  • danielroe
  • whitep4nth3r
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly relates to the changeset, detailing keyboard navigation additions for the org page and tests.
Linked Issues check ✅ Passed The PR fully implements keyboard navigation (ArrowUp/ArrowDown/Enter) for the org page to match search results, resolving issue #2338.
Out of Scope Changes check ✅ Passed All changes are focused on keyboard navigation for org/search pages; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 31, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Scope 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 rewriting committedQuery with 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

📥 Commits

Reviewing files that changed from the base of the PR and between 516808b and 2e616f6.

📒 Files selected for processing (6)
  • app/components/BaseCard.vue
  • app/components/Package/Card.vue
  • app/composables/useResultsKeyboardNavigation.ts
  • app/pages/org/[org].vue
  • app/pages/search.vue
  • test/e2e/interactions.spec.ts

Comment on lines +13 to +15
tabindex="0"
:data-result-index="index"
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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't make every BaseCard instance a tabbable result wrapper.

app/components/SearchSuggestionCard.vue:14-23 still keeps the real interactive target on its inner NuxtLink. With this change, suggestions gain an extra wrapper tab stop, and that link no longer inherits the old card ring because the wrapper now styles only focus-visible while the link itself uses focus-visible:outline-none. Please gate the wrapper tabindex to cards that actually participate in result navigation, or keep the card's focus-within treatment for child-focused consumers.

Possible direction
-    tabindex="0"
+    :tabindex="index != null ? 0 : undefined"

Comment on lines +73 to +145
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 })
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
+    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

  • 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) return

Tests 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

org results page keyboard controls not working

1 participant