Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
- **Install size** – total install size including dependencies
- **Playground links** – quick access to StackBlitz, CodeSandbox, and other demo environments from READMEs
- **Infinite search** – auto-load additional search pages as you scroll
- **Keyboard navigation** – press `/` to focus search, arrow keys to navigate results, Enter to select
- **Claim new packages** – register new package names directly from search results (via local connector)

### User & org pages
Expand Down Expand Up @@ -65,6 +66,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
| Vulnerability warnings | ✅ | ✅ |
| Download charts | ✅ | ✅ |
| Playground links | ❌ | ✅ |
| Keyboard navigation | ❌ | ✅ |
| Dependents list | ✅ | 🚧 |
| Package admin (access/owners) | ✅ | 🚧 |
| Org/team management | ✅ | 🚧 |
Expand Down
25 changes: 13 additions & 12 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'

const route = useRoute()
const router = useRouter()

Expand All @@ -12,9 +14,12 @@ useHead({

// Global keyboard shortcut: "/" focuses search or navigates to search page
function handleGlobalKeydown(e: KeyboardEvent) {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {

const isEditableTarget =
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable

if (isEditableTarget) {
return
}

Expand All @@ -28,20 +33,16 @@ function handleGlobalKeydown(e: KeyboardEvent) {

if (searchInput) {
searchInput.focus()
} else {
// Navigate to search page
router.push('/search')
return
}

router.push('/search')
}
}

onMounted(() => {
document.addEventListener('keydown', handleGlobalKeydown)
})

onUnmounted(() => {
document.removeEventListener('keydown', handleGlobalKeydown)
})
if (import.meta.client) {
useEventListener(document, 'keydown', handleGlobalKeydown)
}
</script>

<template>
Expand Down
16 changes: 14 additions & 2 deletions app/components/PackageCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,27 @@ defineProps<{
/** Whether to show the publisher username */
showPublisher?: boolean
prefetch?: boolean
selected?: boolean
index?: number
}>()

const emit = defineEmits<{
focus: [index: number]
}>()
</script>

<template>
<article class="group card-interactive">
<article
class="group card-interactive scroll-mt-48 scroll-mb-6"
:class="{ 'bg-bg-muted border-border-hover': selected }"
>
<NuxtLink
:to="{ name: 'package', params: { package: result.package.name.split('/') } }"
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
class="block focus:outline-none decoration-none"
class="block focus:outline-none decoration-none scroll-mt-48 scroll-mb-6"
:data-result-index="index"
@focus="index != null && emit('focus', index)"
@mouseenter="index != null && emit('focus', index)"
>
<header class="flex items-start justify-between gap-4 mb-2">
<component
Expand Down
15 changes: 15 additions & 0 deletions app/components/PackageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ const props = defineProps<{
pageSize?: number
/** Initial page to scroll to (1-indexed) */
initialPage?: number
/** Selected result index (for keyboard navigation) */
selectedIndex?: number
}>()

const emit = defineEmits<{
/** Emitted when scrolled near the bottom and more items should be loaded */
loadMore: []
/** Emitted when the visible page changes */
pageChange: [page: number]
/** Emitted when a result is hovered/focused */
select: [index: number]
}>()

// Reference to WindowVirtualizer for infinite scroll detection
Expand Down Expand Up @@ -78,6 +82,14 @@ watch(
}
},
)

function scrollToIndex(index: number, smooth = true) {
listRef.value?.scrollToIndex(index, { align: 'center', smooth })
}

defineExpose({
scrollToIndex,
})
</script>

<template>
Expand All @@ -97,8 +109,11 @@ watch(
:result="item as NpmSearchResult"
:heading-level="headingLevel"
:show-publisher="showPublisher"
:selected="index === (selectedIndex ?? -1)"
:index="index"
class="animate-fade-in animate-fill-both"
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
@focus="emit('select', $event)"
/>
</div>
</template>
Expand Down
5 changes: 4 additions & 1 deletion app/composables/useVirtualInfiniteScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ export interface WindowVirtualizerHandle {
readonly viewportSize: number
findItemIndex: (offset: number) => number
getItemOffset: (index: number) => number
scrollToIndex: (index: number, opts?: { align?: 'start' | 'center' | 'end' }) => void
scrollToIndex: (
index: number,
opts?: { align?: 'start' | 'center' | 'end'; smooth?: boolean },
) => void
}

export interface UseVirtualInfiniteScrollOptions {
Expand Down
77 changes: 76 additions & 1 deletion app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,67 @@ watch(
const isSearchFocused = ref(false)
const searchInputRef = ref<HTMLInputElement>()

const selectedIndex = ref(0)
const packageListRef = useTemplateRef('packageListRef')

const resultCount = computed(() => visibleResults.value?.objects.length ?? 0)

function clampIndex(next: number) {
if (resultCount.value <= 0) return 0
return Math.max(0, Math.min(resultCount.value - 1, next))
}

function scrollToSelectedResult() {
// Use virtualizer's scrollToIndex to ensure the item is rendered and visible
packageListRef.value?.scrollToIndex(selectedIndex.value)
}

function focusSelectedResult() {
// First ensure the item is rendered by scrolling to it
scrollToSelectedResult()
// Then focus it after a tick to allow rendering
nextTick(() => {
const el = document.querySelector<HTMLElement>(`[data-result-index="${selectedIndex.value}"]`)
el?.focus()
})
}

function handleResultsKeydown(e: KeyboardEvent) {
if (resultCount.value <= 0) return

const isFromInput = (e.target as HTMLElement).tagName === 'INPUT'

if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = clampIndex(selectedIndex.value + 1)
// Only move focus if already in results, not when typing in search input
if (isFromInput) {
scrollToSelectedResult()
} else {
focusSelectedResult()
}
return
}

if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = clampIndex(selectedIndex.value - 1)
if (isFromInput) {
scrollToSelectedResult()
} else {
focusSelectedResult()
}
return
}

if (e.key === 'Enter') {
const el = document.querySelector<HTMLElement>(`[data-result-index="${selectedIndex.value}"]`)
if (!el) return
e.preventDefault()
el.click()
}
}

// Track if page just loaded (for hiding "Searching..." during view transition)
const hasInteracted = ref(false)
onMounted(() => {
Expand Down Expand Up @@ -148,12 +209,22 @@ function handlePageChange(page: number) {
updateUrlPage(page)
}

function handleSelect(index: number) {
if (index < 0) return
selectedIndex.value = clampIndex(index)
}

// Reset pages when query changes
watch(query, () => {
loadedPages.value = 1
hasInteracted.value = true
})

// Reset selection when query changes (new search)
watch(query, () => {
selectedIndex.value = 0
})

// Check if current query could be a valid package name
const isValidPackageName = computed(() => isValidNewPackageName(query.value.trim()))

Expand Down Expand Up @@ -299,6 +370,7 @@ defineOgImageComponent('Default', {
class="w-full max-w-full bg-bg-subtle border border-border rounded-lg pl-8 pr-4 py-3 font-mono text-base text-fg placeholder:text-fg-subtle transition-colors duration-300 focus:(border-border-hover outline-none) appearance-none"
@focus="isSearchFocused = true"
@blur="isSearchFocused = false"
@keydown="handleResultsKeydown"
/>
<!-- Hidden submit button for accessibility (form must have submit button per WCAG) -->
<button type="submit" class="sr-only">Search</button>
Expand All @@ -311,7 +383,7 @@ defineOgImageComponent('Default', {

<!-- Results area with container padding -->
<div class="container pt-20 pb-6">
<section v-if="query" aria-label="Search results">
<section v-if="query" aria-label="Search results" @keydown="handleResultsKeydown">
<!-- Initial loading (only after user interaction, not during view transition) -->
<LoadingSpinner v-if="showSearching" text="Searching…" />

Expand Down Expand Up @@ -370,7 +442,9 @@ defineOgImageComponent('Default', {

<PackageList
v-if="visibleResults.objects.length > 0"
ref="packageListRef"
:results="visibleResults.objects"
:selected-index="selectedIndex"
heading-level="h2"
show-publisher
:has-more="hasMore"
Expand All @@ -379,6 +453,7 @@ defineOgImageComponent('Default', {
:initial-page="initialPage"
@load-more="loadMore"
@page-change="handlePageChange"
@select="handleSelect"
/>
</div>
</section>
Expand Down
46 changes: 46 additions & 0 deletions tests/interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect, test } from '@nuxt/test-utils/playwright'

test.describe('Search Pages', () => {
test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })

await expect(page.locator('text=/found \\d+/i')).toBeVisible()

const searchInput = page.locator('input[type="search"]')
await expect(searchInput).toBeFocused()

const firstResult = page.locator('[data-result-index="0"]').first()
await expect(firstResult).toBeVisible()

// First result is selected by default, Enter navigates to it
// URL is /vue not /package/vue (cleaner URLs)
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/\/vue/)

await page.goBack()
// Wait for search page to be ready
await expect(page).toHaveURL(/\/search/)
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
// Search input is autofocused on mount
await expect(searchInput).toBeFocused()

// ArrowDown changes visual selection but keeps focus in input
await page.keyboard.press('ArrowDown')
await expect(searchInput).toBeFocused()

// Enter navigates to the now-selected second result
await page.keyboard.press('Enter')
// Second result could be vue-router, vuex, etc - just check we navigated away
await expect(page).not.toHaveURL(/\/search/)
})

test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })

await expect(page.locator('text=/found \\d+/i')).toBeVisible()

await page.locator('[data-result-index="0"]').first().focus()
await page.keyboard.press('/')
await expect(page.locator('input[type="search"]')).toBeFocused()
})
})