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

feat: keep search box in header (#313)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Garth de Wet
Daniel Roe
and committed by
GitHub
ee73a0e9 817b4a91

+141 -177
+17 -60
app/components/AppHeader.vue
··· 1 1 <script setup lang="ts"> 2 - import { debounce } from 'perfect-debounce' 3 - 4 2 withDefaults( 5 3 defineProps<{ 6 4 showLogo?: boolean ··· 15 13 const { isConnected, npmUser } = useConnector() 16 14 17 15 const router = useRouter() 18 - const route = useRoute() 19 - 20 - const searchQuery = ref('') 21 - const isSearchFocused = ref(false) 22 - 23 - const showSearchBar = computed(() => { 24 - return route.name !== 'search' && route.name !== 'index' 25 - }) 26 16 27 - const debouncedNavigate = debounce(async () => { 28 - const query = searchQuery.value.trim() 29 - await router.push({ 30 - name: 'search', 31 - query: query ? { q: query } : undefined, 32 - }) 33 - // allow time for the navigation to occur before resetting searchQuery 34 - setTimeout(() => (searchQuery.value = ''), 1000) 35 - }, 100) 36 - 37 - async function handleSearchInput() { 38 - debouncedNavigate() 39 - } 17 + const showFullSearch = ref(false) 40 18 41 19 onKeyStroke(',', e => { 42 20 // Don't trigger if user is typing in an input ··· 57 35 class="container h-14 flex items-center justify-start" 58 36 > 59 37 <!-- Start: Logo --> 60 - <div class="flex-shrink-0"> 38 + <div :class="{ 'hidden sm:block': showFullSearch }" class="flex-shrink-0"> 61 39 <NuxtLink 62 40 v-if="showLogo" 63 41 to="/" ··· 71 49 </div> 72 50 73 51 <!-- Center: Search bar + nav items --> 74 - <div class="flex-1 flex items-center justify-center gap-4 sm:gap-6"> 75 - <!-- Search bar (shown on all pages except home and search) --> 76 - <search v-if="showSearchBar" class="hidden sm:block flex-1 max-w-md"> 77 - <form method="GET" action="/search" class="relative" @submit.prevent="handleSearchInput"> 78 - <label for="header-search" class="sr-only"> 79 - {{ $t('search.label') }} 80 - </label> 81 - 82 - <div class="relative group" :class="{ 'is-focused': isSearchFocused }"> 83 - <div class="search-box relative flex items-center"> 84 - <span 85 - class="absolute inset-is-3 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 motion-reduce:transition-none group-focus-within:text-accent z-1" 86 - > 87 - / 88 - </span> 89 - 90 - <input 91 - id="header-search" 92 - v-model="searchQuery" 93 - type="search" 94 - name="q" 95 - :placeholder="$t('search.placeholder')" 96 - v-bind="noCorrect" 97 - class="w-full bg-bg-subtle border border-border rounded-md ps-7 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-border-color duration-300 motion-reduce:transition-none focus:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 98 - @input="handleSearchInput" 99 - @focus="isSearchFocused = true" 100 - @blur="isSearchFocused = false" 101 - /> 102 - <button type="submit" class="sr-only">{{ $t('search.button') }}</button> 103 - </div> 104 - </div> 105 - </form> 106 - </search> 107 - 108 - <ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0"> 52 + <div class="flex-1 flex items-center justify-center md:gap-6 mx-2"> 53 + <!-- Search bar (shown on all pages except home) --> 54 + <SearchBox 55 + :inputClass="showFullSearch ? '' : 'max-w[6rem]'" 56 + @focus="showFullSearch = true" 57 + @blur="showFullSearch = false" 58 + /> 59 + <ul 60 + :class="{ 'hidden sm:flex': showFullSearch }" 61 + class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0" 62 + > 109 63 <!-- Packages dropdown (when connected) --> 110 64 <li v-if="isConnected && npmUser" class="flex items-center"> 111 65 <HeaderPackagesDropdown :username="npmUser" /> ··· 119 73 </div> 120 74 121 75 <!-- End: User status + GitHub --> 122 - <div class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0"> 76 + <div 77 + :class="{ 'hidden sm:flex': showFullSearch }" 78 + class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0" 79 + > 123 80 <NuxtLink 124 81 to="/about" 125 82 class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
+102
app/components/SearchBox.vue
··· 1 + <script setup lang="ts"> 2 + import { debounce } from 'perfect-debounce' 3 + 4 + withDefaults( 5 + defineProps<{ 6 + inputClass?: string 7 + }>(), 8 + { 9 + inputClass: 'inline sm:block', 10 + }, 11 + ) 12 + 13 + const emit = defineEmits(['blur', 'focus']) 14 + 15 + const router = useRouter() 16 + const route = useRoute() 17 + 18 + const isSearchFocused = ref(false) 19 + 20 + const showSearchBar = computed(() => { 21 + return route.name !== 'index' 22 + }) 23 + 24 + // Local input value (updates immediately as user types) 25 + const searchQuery = ref((route.query.q as string) ?? '') 26 + 27 + // Debounced URL update for search query 28 + const updateUrlQuery = debounce((value: string) => { 29 + if (route.name === 'search') { 30 + router.replace({ query: { q: value || undefined } }) 31 + return 32 + } 33 + if (!value) { 34 + return 35 + } 36 + 37 + router.push({ 38 + name: 'search', 39 + query: { 40 + q: value, 41 + }, 42 + }) 43 + }, 250) 44 + 45 + // Watch input and debounce URL updates 46 + watch(searchQuery, value => { 47 + updateUrlQuery(value) 48 + }) 49 + 50 + // Sync input with URL when navigating (e.g., back button) 51 + watch( 52 + () => route.query.q, 53 + urlQuery => { 54 + const value = (urlQuery as string) ?? '' 55 + if (searchQuery.value !== value) { 56 + searchQuery.value = value 57 + } 58 + }, 59 + ) 60 + 61 + function handleSearchBlur() { 62 + isSearchFocused.value = false 63 + emit('blur') 64 + } 65 + function handleSearchFocus() { 66 + isSearchFocused.value = true 67 + emit('focus') 68 + } 69 + </script> 70 + <template> 71 + <search v-if="showSearchBar" :class="'flex-1 sm:max-w-md ' + inputClass"> 72 + <form method="GET" action="/search" class="relative"> 73 + <label for="header-search" class="sr-only"> 74 + {{ $t('search.label') }} 75 + </label> 76 + 77 + <div class="relative group" :class="{ 'is-focused': isSearchFocused }"> 78 + <div class="search-box relative flex items-center"> 79 + <span 80 + class="absolute inset-is-3 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 motion-reduce:transition-none group-focus-within:text-accent z-1" 81 + > 82 + / 83 + </span> 84 + 85 + <input 86 + id="header-search" 87 + autofocus 88 + v-model="searchQuery" 89 + type="search" 90 + name="q" 91 + :placeholder="$t('search.placeholder')" 92 + v-bind="noCorrect" 93 + class="w-full bg-bg-subtle border border-border rounded-md ps-7 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-border-color duration-300 motion-reduce:transition-none focus:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 94 + @focus="handleSearchFocus" 95 + @blur="handleSearchBlur" 96 + /> 97 + <button type="submit" class="sr-only">{{ $t('search.button') }}</button> 98 + </div> 99 + </div> 100 + </form> 101 + </search> 102 + </template>
+7 -114
app/pages/search.vue
··· 1 1 <script setup lang="ts"> 2 2 import { formatNumber } from '#imports' 3 3 import type { FilterChip, SortOption } from '#shared/types/preferences' 4 + import { onKeyDown } from '@vueuse/core' 4 5 import { debounce } from 'perfect-debounce' 5 6 import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name' 6 7 import { isPlatformSpecificPackage } from '~/utils/platform-packages' ··· 18 19 resetColumns, 19 20 } = usePackageListPreferences() 20 21 21 - // Local input value (updates immediately as user types) 22 - const inputValue = ref((route.query.q as string) ?? '') 23 - 24 - // Debounced URL update for search query 25 - const updateUrlQuery = debounce((value: string) => { 26 - router.replace({ query: { q: value || undefined } }) 27 - }, 250) 28 - 29 22 // Debounced URL update for page (less aggressive to avoid too many URL changes) 30 23 const updateUrlPage = debounce((page: number) => { 31 24 router.replace({ ··· 36 29 }) 37 30 }, 500) 38 31 39 - // Watch input and debounce URL updates 40 - watch(inputValue, value => { 41 - updateUrlQuery(value) 42 - }) 43 - 44 32 // The actual search query (from URL, used for API calls) 45 33 const query = computed(() => (route.query.q as string) ?? '') 46 34 47 - // Sync input with URL when navigating (e.g., back button) 48 - watch( 49 - () => route.query.q, 50 - urlQuery => { 51 - const value = (urlQuery as string) ?? '' 52 - if (inputValue.value !== value) { 53 - inputValue.value = value 54 - } 55 - }, 56 - ) 57 - 58 - // For glow effect 59 - const searchInputRef = useTemplateRef('searchInputRef') 60 - const { focused: isSearchFocused } = useFocus(searchInputRef) 61 - 62 35 const selectedIndex = ref(0) 63 36 const packageListRef = useTemplateRef('packageListRef') 64 37 65 38 // Track if page just loaded (for hiding "Searching..." during view transition) 66 39 const hasInteracted = ref(false) 67 40 onMounted(() => { 68 - // Focus search onMount 69 - isSearchFocused.value = true 70 41 // Small delay to let view transition complete 71 42 setTimeout(() => { 72 43 hasInteracted.value = true ··· 668 639 } 669 640 } 670 641 671 - function focusSelectedItem() { 672 - const suggIdx = toSuggestionIndex(unifiedSelectedIndex.value) 673 - const pkgIdx = toPackageIndex(unifiedSelectedIndex.value) 674 - 675 - nextTick(() => { 676 - if (suggIdx !== null) { 677 - const el = document.querySelector<HTMLElement>(`[data-suggestion-index="${suggIdx}"]`) 678 - el?.focus() 679 - } else if (pkgIdx !== null) { 680 - scrollToSelectedItem() 681 - nextTick(() => { 682 - const el = document.querySelector<HTMLElement>(`[data-result-index="${pkgIdx}"]`) 683 - el?.focus() 684 - }) 685 - } 686 - }) 687 - } 688 - 689 642 function handleResultsKeydown(e: KeyboardEvent) { 690 643 if (totalSelectableCount.value <= 0) return 691 644 692 - const isFromInput = (e.target as HTMLElement).tagName === 'INPUT' 693 - 694 645 if (e.key === 'ArrowDown') { 695 646 e.preventDefault() 696 647 userHasNavigated.value = true 697 648 unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value + 1) 698 - if (isFromInput) { 699 - scrollToSelectedItem() 700 - } else { 701 - focusSelectedItem() 702 - } 649 + scrollToSelectedItem() 703 650 return 704 651 } 705 652 ··· 707 654 e.preventDefault() 708 655 userHasNavigated.value = true 709 656 unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value - 1) 710 - if (isFromInput) { 711 - scrollToSelectedItem() 712 - } else { 713 - focusSelectedItem() 714 - } 657 + scrollToSelectedItem() 715 658 return 716 659 } 717 660 ··· 736 679 } 737 680 } 738 681 } 682 + 683 + onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown) 739 684 740 685 function handleSuggestionSelect(index: number) { 741 686 // Convert suggestion index to unified index ··· 759 704 760 705 <template> 761 706 <main class="flex-1 overflow-x-hidden"> 762 - <!-- Sticky search header - positioned below AppHeader (h-14 = 56px) --> 763 - <header class="sticky top-14 z-40 bg-bg/95 backdrop-blur-sm border-b border-border"> 764 - <div class="container-sm py-4"> 765 - <h1 class="font-mono text-xl sm:text-2xl font-medium mb-4">{{ $t('nav.search') }}</h1> 766 - 767 - <search> 768 - <form method="GET" action="/search" class="relative" @submit.prevent> 769 - <label for="search-input" class="sr-only">{{ $t('search.label') }}</label> 770 - 771 - <div class="relative group" :class="{ 'is-focused': isSearchFocused }"> 772 - <!-- Subtle glow effect --> 773 - <div 774 - class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100 motion-reduce:transition-none" 775 - /> 776 - 777 - <div class="search-box relative flex items-center"> 778 - <span 779 - class="absolute inset-is-4 text-fg-subtle font-mono text-base pointer-events-none transition-colors duration-200 group-focus-within:text-accent" 780 - aria-hidden="true" 781 - > 782 - / 783 - </span> 784 - <input 785 - id="search-input" 786 - ref="searchInputRef" 787 - v-model="inputValue" 788 - type="search" 789 - name="q" 790 - :placeholder="$t('search.placeholder')" 791 - v-bind="noCorrect" 792 - autofocus 793 - class="w-full max-w-full bg-bg-subtle border border-border rounded-lg ps-8 pe-10 py-3 font-mono text-base text-fg placeholder:text-fg-subtle transition-colors duration-300 focus:border-accent focus-visible:outline-none appearance-none" 794 - @keydown="handleResultsKeydown" 795 - /> 796 - <button 797 - v-show="inputValue" 798 - type="button" 799 - class="absolute inset-ie-3 p-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 800 - :aria-label="$t('search.clear')" 801 - @click="inputValue = ''" 802 - > 803 - <span class="i-carbon:close-large block w-3.5 h-3.5" aria-hidden="true" /> 804 - </button> 805 - <!-- Hidden submit button for accessibility (form must have submit button per WCAG) --> 806 - <button type="submit" class="sr-only">{{ $t('search.button') }}</button> 807 - </div> 808 - </div> 809 - </form> 810 - </search> 811 - </div> 812 - </header> 813 - 814 707 <!-- Results area with container padding --> 815 - <div class="container-sm pt-20 pb-6"> 816 - <section v-if="query" :aria-label="$t('search.results')" @keydown="handleResultsKeydown"> 708 + <div class="container-sm py-6"> 709 + <section v-if="query" :aria-label="$t('search.results')"> 817 710 <!-- Initial loading (only after user interaction, not during view transition) --> 818 711 <LoadingSpinner v-if="showSearching" :text="$t('search.searching')" /> 819 712
+15 -3
tests/interactions.spec.ts
··· 6 6 7 7 await expect(page.locator('text=/found \\d+/i')).toBeVisible() 8 8 9 - const searchInput = page.locator('input[type="search"]') 10 - await expect(searchInput).toBeFocused() 11 - 12 9 const firstResult = page.locator('[data-result-index="0"]').first() 13 10 await expect(firstResult).toBeVisible() 11 + 12 + const searchInput = page.locator('input[type="search"]') 14 13 15 14 // ArrowDown changes visual selection but keeps focus in input 16 15 await page.keyboard.press('ArrowDown') ··· 34 33 await page.locator('[data-result-index="0"]').first().focus() 35 34 await page.keyboard.press('/') 36 35 await expect(page.locator('input[type="search"]')).toBeFocused() 36 + }) 37 + 38 + test('/settings → search, keeps focus on search input', async ({ page, goto }) => { 39 + await goto('/settings', { waitUntil: 'domcontentloaded' }) 40 + 41 + const searchInput = page.locator('input[type="search"]') 42 + await searchInput.fill('vue') 43 + 44 + await page.waitForLoadState('domcontentloaded') 45 + 46 + await expect(page.locator('text=/found \\d+/i')).toBeVisible() 47 + 48 + await expect(searchInput).toBeFocused() 37 49 }) 38 50 })