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
13 changes: 13 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ function expandMobileSearch() {
})
}

watch(
isOnSearchPage,
visible => {
if (!visible) return

searchBoxRef.value?.focus()
nextTick(() => {
searchBoxRef.value?.focus()
})
},
{ flush: 'sync' },
)

function handleSearchBlur() {
showFullSearch.value = false
// Collapse expanded search on mobile after blur (with delay for click handling)
Expand Down
3 changes: 0 additions & 3 deletions app/components/SearchBox.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'

const isMobile = useIsMobile()

withDefaults(
defineProps<{
inputClass?: string
Expand Down Expand Up @@ -107,7 +105,6 @@ defineExpose({ focus })
<input
id="header-search"
ref="inputRef"
:autofocus="!isMobile"
v-model="searchQuery"
type="search"
name="q"
Expand Down
29 changes: 15 additions & 14 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'

const router = useRouter()
const searchQuery = shallowRef('')
const searchInputRef = useTemplateRef('searchInputRef')
const { focused: isSearchFocused } = useFocus(searchInputRef)

const isMobile = useIsMobile()

const debouncedNavigate = debounce(() => {
router.push({
async function search() {
const query = searchQuery.value.trim()
await navigateTo({
path: '/search',
query: searchQuery.value.trim() ? { q: searchQuery.value.trim() } : undefined,
query: query ? { q: query } : undefined,
})
}, 250)

function handleSearch() {
// If input is empty, navigate immediately (no need to debounce)
return searchQuery.value.trim() ? debouncedNavigate() : router.push('/search')
const newQuery = searchQuery.value.trim()
if (newQuery !== query) {
await search()
}
}

const handleInput = isTouchDevice()
? search
: debounce(search, 250, { leading: true, trailing: true })

useSeoMeta({
title: () => $t('seo.home.title'),
description: () => $t('seo.home.description'),
Expand Down Expand Up @@ -64,7 +65,7 @@ defineOgImageComponent('Default', {
class="w-full max-w-xl motion-safe:animate-slide-up motion-safe:animate-fill-both"
style="animation-delay: 0.2s"
>
<form method="GET" action="/search" class="relative" @submit.prevent="handleSearch">
<form method="GET" action="/search" class="relative" @submit.prevent.trim="search">
<label for="home-search" class="sr-only">
{{ $t('search.label') }}
</label>
Expand All @@ -89,11 +90,11 @@ defineOgImageComponent('Default', {
v-model="searchQuery"
type="search"
name="q"
autofocus
:placeholder="$t('search.placeholder')"
v-bind="noCorrect"
:autofocus="!isMobile"
class="w-full bg-bg-subtle border border-border rounded-lg ps-8 pe-24 py-4 font-mono text-base text-fg placeholder:text-fg-subtle transition-border-color duration-300 focus:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
@input="handleSearch"
@input="handleInput"
/>

<button
Expand Down
7 changes: 7 additions & 0 deletions app/utils/responsive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @public */
export function isTouchDevice() {
if (import.meta.server) {
return false
}
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
21 changes: 16 additions & 5 deletions test/e2e/create-command.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { expect, test } from '@nuxt/test-utils/playwright'

test.describe('Create Command', () => {
// TODO: these tests depend on external npm registry API - we should add data fixtures
test.describe.configure({ retries: 2 })

test.describe('Visibility', () => {
test('/vite - should show create command (same maintainers)', async ({ page, goto }) => {
await goto('/vite', { waitUntil: 'domcontentloaded' })
Expand Down Expand Up @@ -80,13 +83,15 @@ test.describe('Create Command', () => {
test('hovering create command shows copy button', async ({ page, goto }) => {
await goto('/vite', { waitUntil: 'hydration' })

// Wait for package analysis API to load (create command requires this)
// First ensure the package page has loaded
await expect(page.locator('h1')).toContainText('vite')
await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })

await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({
timeout: 15000,
})

// Find the create command container (wait longer for API response)
const createCommandContainer = page.locator('.group\\/createcmd').first()
await expect(createCommandContainer).toBeVisible({ timeout: 15000 })
await expect(createCommandContainer).toBeVisible({ timeout: 20000 })

// Copy button should initially be hidden (opacity-0)
const copyButton = createCommandContainer.locator('button')
Expand All @@ -108,9 +113,15 @@ test.describe('Create Command', () => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])

await goto('/vite', { waitUntil: 'hydration' })
await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })

await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({
timeout: 15000,
})

// Find and hover over the create command container
const createCommandContainer = page.locator('.group\\/createcmd').first()
await expect(createCommandContainer).toBeVisible({ timeout: 20000 })

await createCommandContainer.hover()

// Click the copy button
Expand Down
58 changes: 43 additions & 15 deletions test/e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,78 @@
import { expect, test } from '@nuxt/test-utils/playwright'

test.describe('Search Pages', () => {
// TODO: these tests depend on external npm registry API - we should add data fixtures
test.describe.configure({ retries: 2 })
test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
await goto('/search?q=vue', { waitUntil: 'hydration' })

await expect(page.locator('text=/found \\d+/i')).toBeVisible()
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
timeout: 15000,
})

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

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

// ArrowDown changes visual selection but keeps focus in input
// Global keyboard navigation works regardless of focus
// ArrowDown selects the next result
await page.keyboard.press('ArrowDown')
await expect(searchInput).toBeFocused()

// ArrowUp goes back to first result
// ArrowUp selects the previous result
await page.keyboard.press('ArrowUp')
await expect(searchInput).toBeFocused()

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

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

await expect(page.locator('text=/found \\d+/i')).toBeVisible()
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
timeout: 15000,
})

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

test('/ (homepage) → search, keeps focus on search input', async ({ page, goto }) => {
await goto('/', { waitUntil: 'hydration' })

const homeSearchInput = page.locator('#home-search')
await homeSearchInput.click()
await page.keyboard.type('vue')

// Wait for navigation to /search (debounce is 250ms)
await expect(page).toHaveURL(/\/search/, { timeout: 10000 })

await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 })

// Home search input should be gone (we're on /search now)
await expect(homeSearchInput).not.toBeVisible()

// Header search input should now exist and be focused
const headerSearchInput = page.locator('#header-search')
await expect(headerSearchInput).toBeVisible()
await expect(headerSearchInput).toBeFocused()
})

test('/settings → search, keeps focus on search input', async ({ page, goto }) => {
await goto('/settings', { waitUntil: 'domcontentloaded' })
await goto('/settings', { waitUntil: 'hydration' })

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

await searchInput.click()
await searchInput.fill('vue')

await page.waitForURL(/\/search/)
await expect(page).toHaveURL(/\/search/, { timeout: 10000 })

await expect(page.locator('text=/found \\d+/i')).toBeVisible()
await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 })

await expect(searchInput).toBeFocused()
const headerSearchInput = page.locator('#header-search')
await expect(headerSearchInput).toBeFocused()
})
})
7 changes: 5 additions & 2 deletions test/e2e/package-manager-select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import { expect, test } from '@nuxt/test-utils/playwright'

test.describe('Package Page', () => {
test('/vue → package manager select dropdown works', async ({ page, goto }) => {
await goto('/vue', { waitUntil: 'domcontentloaded' })
await goto('/vue', { waitUntil: 'hydration' })

await expect(page.locator('h1')).toContainText('vue', { timeout: 15000 })

const packageManagerButton = page.locator('button[aria-haspopup="listbox"]').first()
await expect(packageManagerButton).toBeVisible()

// Open dropdown
await packageManagerButton.click()
const packageManagerDropdown = page.locator('[role="listbox"]')
await expect(packageManagerDropdown).toBeVisible()
await expect(packageManagerDropdown).toBeVisible({ timeout: 5000 })

// Arrow keys navigate the listbox
await packageManagerButton.press('ArrowDown')
Expand All @@ -26,6 +28,7 @@ test.describe('Package Page', () => {

// Enter selects option and closes dropdown
await packageManagerButton.click()
await expect(packageManagerDropdown).toBeVisible({ timeout: 5000 })
await packageManagerButton.press('ArrowDown')
await packageManagerButton.press('Enter')
await expect(packageManagerDropdown).not.toBeVisible()
Expand Down
11 changes: 8 additions & 3 deletions test/e2e/url-compatibility.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { expect, test } from '@nuxt/test-utils/playwright'

test.describe('npmjs.com URL Compatibility', () => {
// TODO: these tests depend on external npm registry API - we should add data fixtures
test.describe.configure({ retries: 2 })

test.describe('Package Pages', () => {
test('/package/vue → package page', async ({ page, goto }) => {
await goto('/package/vue', { waitUntil: 'domcontentloaded' })
Expand Down Expand Up @@ -73,12 +76,14 @@ test.describe('npmjs.com URL Compatibility', () => {

test.describe('User Profile Pages', () => {
test('/~sindresorhus → user profile', async ({ page, goto }) => {
await goto('/~sindresorhus', { waitUntil: 'domcontentloaded' })
await goto('/~sindresorhus', { waitUntil: 'hydration' })

// Should show username
await expect(page.locator('h1')).toContainText('~sindresorhus')
// Should show packages heading (user has packages)
await expect(page.getByRole('heading', { name: 'Packages' })).toBeVisible()

await expect(page.locator('text=/\\d+\\s+public\\s+package/i').first()).toBeVisible({
timeout: 15000,
})
})

test('/~nonexistent-user-12345 → empty user handling', async ({ page, goto }) => {
Expand Down
Loading