[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: mobile + desktop search focus fixes (#526)

Co-authored-by: Florian Heuberger <fh@flogersoft.de>
Co-authored-by: cullophid <andreas.moller@gmail.com>

authored by

Daniel Roe
Florian Heuberger
cullophid
and committed by
GitHub
dd17a768 731d9a6c

+107 -42
+13
app/components/AppHeader.vue
··· 30 30 }) 31 31 } 32 32 33 + watch( 34 + isOnSearchPage, 35 + visible => { 36 + if (!visible) return 37 + 38 + searchBoxRef.value?.focus() 39 + nextTick(() => { 40 + searchBoxRef.value?.focus() 41 + }) 42 + }, 43 + { flush: 'sync' }, 44 + ) 45 + 33 46 function handleSearchBlur() { 34 47 showFullSearch.value = false 35 48 // Collapse expanded search on mobile after blur (with delay for click handling)
-3
app/components/SearchBox.vue
··· 1 1 <script setup lang="ts"> 2 2 import { debounce } from 'perfect-debounce' 3 3 4 - const isMobile = useIsMobile() 5 - 6 4 withDefaults( 7 5 defineProps<{ 8 6 inputClass?: string ··· 107 105 <input 108 106 id="header-search" 109 107 ref="inputRef" 110 - :autofocus="!isMobile" 111 108 v-model="searchQuery" 112 109 type="search" 113 110 name="q"
+15 -14
app/pages/index.vue
··· 1 1 <script setup lang="ts"> 2 2 import { debounce } from 'perfect-debounce' 3 3 4 - const router = useRouter() 5 4 const searchQuery = shallowRef('') 6 5 const searchInputRef = useTemplateRef('searchInputRef') 7 6 const { focused: isSearchFocused } = useFocus(searchInputRef) 8 7 9 - const isMobile = useIsMobile() 10 - 11 - const debouncedNavigate = debounce(() => { 12 - router.push({ 8 + async function search() { 9 + const query = searchQuery.value.trim() 10 + await navigateTo({ 13 11 path: '/search', 14 - query: searchQuery.value.trim() ? { q: searchQuery.value.trim() } : undefined, 12 + query: query ? { q: query } : undefined, 15 13 }) 16 - }, 250) 14 + const newQuery = searchQuery.value.trim() 15 + if (newQuery !== query) { 16 + await search() 17 + } 18 + } 17 19 18 - function handleSearch() { 19 - // If input is empty, navigate immediately (no need to debounce) 20 - return searchQuery.value.trim() ? debouncedNavigate() : router.push('/search') 21 - } 20 + const handleInput = isTouchDevice() 21 + ? search 22 + : debounce(search, 250, { leading: true, trailing: true }) 22 23 23 24 useSeoMeta({ 24 25 title: () => $t('seo.home.title'), ··· 64 65 class="w-full max-w-xl motion-safe:animate-slide-up motion-safe:animate-fill-both" 65 66 style="animation-delay: 0.2s" 66 67 > 67 - <form method="GET" action="/search" class="relative" @submit.prevent="handleSearch"> 68 + <form method="GET" action="/search" class="relative" @submit.prevent.trim="search"> 68 69 <label for="home-search" class="sr-only"> 69 70 {{ $t('search.label') }} 70 71 </label> ··· 89 90 v-model="searchQuery" 90 91 type="search" 91 92 name="q" 93 + autofocus 92 94 :placeholder="$t('search.placeholder')" 93 95 v-bind="noCorrect" 94 - :autofocus="!isMobile" 95 96 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" 96 - @input="handleSearch" 97 + @input="handleInput" 97 98 /> 98 99 99 100 <button
+7
app/utils/responsive.ts
··· 1 + /** @public */ 2 + export function isTouchDevice() { 3 + if (import.meta.server) { 4 + return false 5 + } 6 + return 'ontouchstart' in window || navigator.maxTouchPoints > 0 7 + }
+16 -5
test/e2e/create-command.spec.ts
··· 1 1 import { expect, test } from '@nuxt/test-utils/playwright' 2 2 3 3 test.describe('Create Command', () => { 4 + // TODO: these tests depend on external npm registry API - we should add data fixtures 5 + test.describe.configure({ retries: 2 }) 6 + 4 7 test.describe('Visibility', () => { 5 8 test('/vite - should show create command (same maintainers)', async ({ page, goto }) => { 6 9 await goto('/vite', { waitUntil: 'domcontentloaded' }) ··· 80 83 test('hovering create command shows copy button', async ({ page, goto }) => { 81 84 await goto('/vite', { waitUntil: 'hydration' }) 82 85 83 - // Wait for package analysis API to load (create command requires this) 84 - // First ensure the package page has loaded 85 - await expect(page.locator('h1')).toContainText('vite') 86 + await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) 87 + 88 + await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({ 89 + timeout: 15000, 90 + }) 86 91 87 92 // Find the create command container (wait longer for API response) 88 93 const createCommandContainer = page.locator('.group\\/createcmd').first() 89 - await expect(createCommandContainer).toBeVisible({ timeout: 15000 }) 94 + await expect(createCommandContainer).toBeVisible({ timeout: 20000 }) 90 95 91 96 // Copy button should initially be hidden (opacity-0) 92 97 const copyButton = createCommandContainer.locator('button') ··· 108 113 await context.grantPermissions(['clipboard-read', 'clipboard-write']) 109 114 110 115 await goto('/vite', { waitUntil: 'hydration' }) 116 + await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 }) 111 117 112 - // Find and hover over the create command container 118 + await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({ 119 + timeout: 15000, 120 + }) 121 + 113 122 const createCommandContainer = page.locator('.group\\/createcmd').first() 123 + await expect(createCommandContainer).toBeVisible({ timeout: 20000 }) 124 + 114 125 await createCommandContainer.hover() 115 126 116 127 // Click the copy button
+43 -15
test/e2e/interactions.spec.ts
··· 1 1 import { expect, test } from '@nuxt/test-utils/playwright' 2 2 3 3 test.describe('Search Pages', () => { 4 + // TODO: these tests depend on external npm registry API - we should add data fixtures 5 + test.describe.configure({ retries: 2 }) 4 6 test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => { 5 - await goto('/search?q=vue', { waitUntil: 'domcontentloaded' }) 7 + await goto('/search?q=vue', { waitUntil: 'hydration' }) 6 8 7 - await expect(page.locator('text=/found \\d+/i')).toBeVisible() 9 + await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({ 10 + timeout: 15000, 11 + }) 8 12 9 13 const firstResult = page.locator('[data-result-index="0"]').first() 10 14 await expect(firstResult).toBeVisible() 11 15 12 - const searchInput = page.locator('input[type="search"]') 13 - 14 - // ArrowDown changes visual selection but keeps focus in input 16 + // Global keyboard navigation works regardless of focus 17 + // ArrowDown selects the next result 15 18 await page.keyboard.press('ArrowDown') 16 - await expect(searchInput).toBeFocused() 17 19 18 - // ArrowUp goes back to first result 20 + // ArrowUp selects the previous result 19 21 await page.keyboard.press('ArrowUp') 20 - await expect(searchInput).toBeFocused() 21 22 22 - // First result is selected, Enter navigates to it 23 + // Enter navigates to the selected result 23 24 // URL is /vue not /package/vue (cleaner URLs) 24 25 await page.keyboard.press('Enter') 25 26 await expect(page).toHaveURL(/\/vue/) 26 27 }) 27 28 28 29 test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => { 29 - await goto('/search?q=vue', { waitUntil: 'domcontentloaded' }) 30 + await goto('/search?q=vue', { waitUntil: 'hydration' }) 30 31 31 - await expect(page.locator('text=/found \\d+/i')).toBeVisible() 32 + await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({ 33 + timeout: 15000, 34 + }) 32 35 33 36 await page.locator('[data-result-index="0"]').first().focus() 34 37 await page.keyboard.press('/') 35 38 await expect(page.locator('input[type="search"]')).toBeFocused() 36 39 }) 37 40 41 + test('/ (homepage) → search, keeps focus on search input', async ({ page, goto }) => { 42 + await goto('/', { waitUntil: 'hydration' }) 43 + 44 + const homeSearchInput = page.locator('#home-search') 45 + await homeSearchInput.click() 46 + await page.keyboard.type('vue') 47 + 48 + // Wait for navigation to /search (debounce is 250ms) 49 + await expect(page).toHaveURL(/\/search/, { timeout: 10000 }) 50 + 51 + await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 }) 52 + 53 + // Home search input should be gone (we're on /search now) 54 + await expect(homeSearchInput).not.toBeVisible() 55 + 56 + // Header search input should now exist and be focused 57 + const headerSearchInput = page.locator('#header-search') 58 + await expect(headerSearchInput).toBeVisible() 59 + await expect(headerSearchInput).toBeFocused() 60 + }) 61 + 38 62 test('/settings → search, keeps focus on search input', async ({ page, goto }) => { 39 - await goto('/settings', { waitUntil: 'domcontentloaded' }) 63 + await goto('/settings', { waitUntil: 'hydration' }) 40 64 41 65 const searchInput = page.locator('input[type="search"]') 66 + await expect(searchInput).toBeVisible() 67 + 68 + await searchInput.click() 42 69 await searchInput.fill('vue') 43 70 44 - await page.waitForURL(/\/search/) 71 + await expect(page).toHaveURL(/\/search/, { timeout: 10000 }) 45 72 46 - await expect(page.locator('text=/found \\d+/i')).toBeVisible() 73 + await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 }) 47 74 48 - await expect(searchInput).toBeFocused() 75 + const headerSearchInput = page.locator('#header-search') 76 + await expect(headerSearchInput).toBeFocused() 49 77 }) 50 78 })
+5 -2
test/e2e/package-manager-select.spec.ts
··· 2 2 3 3 test.describe('Package Page', () => { 4 4 test('/vue → package manager select dropdown works', async ({ page, goto }) => { 5 - await goto('/vue', { waitUntil: 'domcontentloaded' }) 5 + await goto('/vue', { waitUntil: 'hydration' }) 6 + 7 + await expect(page.locator('h1')).toContainText('vue', { timeout: 15000 }) 6 8 7 9 const packageManagerButton = page.locator('button[aria-haspopup="listbox"]').first() 8 10 await expect(packageManagerButton).toBeVisible() ··· 10 12 // Open dropdown 11 13 await packageManagerButton.click() 12 14 const packageManagerDropdown = page.locator('[role="listbox"]') 13 - await expect(packageManagerDropdown).toBeVisible() 15 + await expect(packageManagerDropdown).toBeVisible({ timeout: 5000 }) 14 16 15 17 // Arrow keys navigate the listbox 16 18 await packageManagerButton.press('ArrowDown') ··· 26 28 27 29 // Enter selects option and closes dropdown 28 30 await packageManagerButton.click() 31 + await expect(packageManagerDropdown).toBeVisible({ timeout: 5000 }) 29 32 await packageManagerButton.press('ArrowDown') 30 33 await packageManagerButton.press('Enter') 31 34 await expect(packageManagerDropdown).not.toBeVisible()
+8 -3
test/e2e/url-compatibility.spec.ts
··· 1 1 import { expect, test } from '@nuxt/test-utils/playwright' 2 2 3 3 test.describe('npmjs.com URL Compatibility', () => { 4 + // TODO: these tests depend on external npm registry API - we should add data fixtures 5 + test.describe.configure({ retries: 2 }) 6 + 4 7 test.describe('Package Pages', () => { 5 8 test('/package/vue → package page', async ({ page, goto }) => { 6 9 await goto('/package/vue', { waitUntil: 'domcontentloaded' }) ··· 73 76 74 77 test.describe('User Profile Pages', () => { 75 78 test('/~sindresorhus → user profile', async ({ page, goto }) => { 76 - await goto('/~sindresorhus', { waitUntil: 'domcontentloaded' }) 79 + await goto('/~sindresorhus', { waitUntil: 'hydration' }) 77 80 78 81 // Should show username 79 82 await expect(page.locator('h1')).toContainText('~sindresorhus') 80 - // Should show packages heading (user has packages) 81 - await expect(page.getByRole('heading', { name: 'Packages' })).toBeVisible() 83 + 84 + await expect(page.locator('text=/\\d+\\s+public\\s+package/i').first()).toBeVisible({ 85 + timeout: 15000, 86 + }) 82 87 }) 83 88 84 89 test('/~nonexistent-user-12345 → empty user handling', async ({ page, goto }) => {