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

perf: use focus to navigate search results (#587)

authored by

Daniel Roe and committed by
GitHub
2f746805 5cc9f20a

+45 -170
+1 -9
app/components/PackageCard.vue
··· 7 7 /** Whether to show the publisher username */ 8 8 showPublisher?: boolean 9 9 prefetch?: boolean 10 - selected?: boolean 11 10 index?: number 12 11 /** Search query for highlighting exact matches */ 13 12 searchQuery?: string ··· 20 19 const name = props.result.package.name.toLowerCase() 21 20 return query === name 22 21 }) 23 - 24 - const emit = defineEmits<{ 25 - focus: [index: number] 26 - }>() 27 22 </script> 28 23 29 24 <template> 30 25 <article 31 - class="group card-interactive scroll-mt-48 scroll-mb-6 relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50" 26 + class="group card-interactive scroll-mt-48 scroll-mb-6 relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 focus-within:bg-bg-muted focus-within:border-border-hover" 32 27 :class="{ 33 - 'bg-bg-muted border-border-hover': selected, 34 28 'border-accent/30 bg-accent/5': isExactMatch, 35 29 }" 36 30 > ··· 50 44 :prefetch-on="prefetch ? 'visibility' : 'interaction'" 51 45 class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0" 52 46 :data-result-index="index" 53 - @focus="index != null && emit('focus', index)" 54 - @mouseenter="index != null && emit('focus', index)" 55 47 >{{ result.package.name }}</NuxtLink 56 48 > 57 49 <span
-11
app/components/PackageList.vue
··· 29 29 pageSize?: PageSize 30 30 /** Initial page to scroll to (1-indexed) */ 31 31 initialPage?: number 32 - /** Selected result index (for keyboard navigation) */ 33 - selectedIndex?: number 34 32 /** Search query for highlighting exact matches */ 35 33 searchQuery?: string 36 34 /** View mode: cards or table */ ··· 48 46 'loadMore': [] 49 47 /** Emitted when the visible page changes */ 50 48 'pageChange': [page: number] 51 - /** Emitted when a result is hovered/focused */ 52 - 'select': [index: number] 53 49 /** Emitted when sort option changes (table view) */ 54 50 'update:sortOption': [option: SortOption] 55 51 /** Emitted when a keyword is clicked */ ··· 153 149 :results="displayedResults" 154 150 :columns="columns" 155 151 v-model:sort-option="sortOption" 156 - :selected-index="selectedIndex" 157 152 :is-loading="isLoading" 158 - @select="emit('select', $event)" 159 153 @click-keyword="emit('clickKeyword', $event)" 160 154 /> 161 155 </template> ··· 179 173 :result="item as NpmSearchResult" 180 174 :heading-level="headingLevel" 181 175 :show-publisher="showPublisher" 182 - :selected="index === (selectedIndex ?? -1)" 183 176 :index="index" 184 177 :search-query="searchQuery" 185 178 class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 186 179 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 187 - @focus="emit('select', $event)" 188 180 /> 189 181 </div> 190 182 </template> ··· 199 191 :result="item" 200 192 :heading-level="headingLevel" 201 193 :show-publisher="showPublisher" 202 - :selected="index === (selectedIndex ?? -1)" 203 194 :index="index" 204 195 :search-query="searchQuery" 205 196 /> ··· 230 221 :result="item" 231 222 :heading-level="headingLevel" 232 223 :show-publisher="showPublisher" 233 - :selected="index === (selectedIndex ?? -1)" 234 224 :index="index" 235 225 :search-query="searchQuery" 236 226 class="motion-safe:animate-fade-in motion-safe:animate-fill-both" 237 227 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 238 - @focus="emit('select', $event)" 239 228 /> 240 229 </li> 241 230 </ol>
-4
app/components/PackageTable.vue
··· 6 6 const props = defineProps<{ 7 7 results: NpmSearchResult[] 8 8 columns: ColumnConfig[] 9 - selectedIndex?: number 10 9 isLoading?: boolean 11 10 }>() 12 11 13 12 const sortOption = defineModel<SortOption>('sortOption') 14 13 15 14 const emit = defineEmits<{ 16 - select: [index: number] 17 15 clickKeyword: [keyword: string] 18 16 }>() 19 17 ··· 318 316 :key="result.package.name" 319 317 :result="result" 320 318 :columns="columns" 321 - :selected="selectedIndex === index" 322 319 :index="index" 323 - @focus="emit('select', index)" 324 320 @click-keyword="emit('clickKeyword', $event)" 325 321 /> 326 322 </template>
+2 -5
app/components/PackageTableRow.vue
··· 5 5 const props = defineProps<{ 6 6 result: NpmSearchResult 7 7 columns: ColumnConfig[] 8 - selected?: boolean 9 8 index?: number 10 9 }>() 11 10 12 11 const emit = defineEmits<{ 13 - focus: [] 14 12 clickKeyword: [keyword: string] 15 13 }>() 16 14 ··· 45 43 46 44 <template> 47 45 <tr 48 - class="border-b border-border hover:bg-bg-muted transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none" 49 - :class="{ 'bg-bg-muted': selected }" 46 + class="border-b border-border hover:bg-bg-muted transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none focus:bg-bg-muted" 50 47 tabindex="0" 51 - @focus="emit('focus')" 48 + :data-result-index="index" 52 49 > 53 50 <!-- Name (always visible) --> 54 51 <td class="py-2 px-3">
+1 -10
app/components/SearchSuggestionCard.vue
··· 4 4 type: 'user' | 'org' 5 5 /** The name (username or org name) */ 6 6 name: string 7 - /** Whether this suggestion is currently selected (keyboard nav) */ 8 - selected?: boolean 9 7 /** Whether this is an exact match for the query */ 10 8 isExactMatch?: boolean 11 9 /** Index for keyboard navigation */ 12 10 index?: number 13 - }>() 14 - 15 - const emit = defineEmits<{ 16 - focus: [index: number] 17 11 }>() 18 12 </script> 19 13 20 14 <template> 21 15 <article 22 - class="group card-interactive relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50" 16 + class="group card-interactive relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 focus-within:bg-bg-muted focus-within:border-border-hover" 23 17 :class="{ 24 - 'bg-bg-muted border-border-hover': selected, 25 18 'border-accent/30 bg-accent/5': isExactMatch, 26 19 }" 27 20 > ··· 35 28 :to="type === 'user' ? `/~${name}` : `/@${name}`" 36 29 :data-suggestion-index="index" 37 30 class="flex items-center gap-4 focus-visible:outline-none after:content-[''] after:absolute after:inset-0" 38 - @focus="index != null && emit('focus', index)" 39 - @mouseenter="index != null && emit('focus', index)" 40 31 > 41 32 <!-- Avatar placeholder --> 42 33 <div
+41 -131
app/pages/search.vue
··· 31 31 // The actual search query (from URL, used for API calls) 32 32 const query = computed(() => (route.query.q as string) ?? '') 33 33 34 - const selectedIndex = shallowRef(0) 35 34 const packageListRef = useTemplateRef('packageListRef') 36 35 37 36 // Track if page just loaded (for hiding "Searching..." during view transition) ··· 218 217 watch(query, () => { 219 218 currentPage.value = 1 220 219 hasInteracted.value = true 221 - }) 222 - 223 - // Reset selection when query changes (new search) 224 - watch(query, () => { 225 - selectedIndex.value = 0 226 220 }) 227 221 228 222 // Check if current query could be a valid package name ··· 542 536 return null 543 537 }) 544 538 545 - /** 546 - * Selection uses negative indices for suggestions, positive for packages 547 - * -2 = first suggestion, -1 = second suggestion, 0+ = package indices 548 - */ 549 539 const suggestionCount = computed(() => validatedSuggestions.value.length) 550 540 const totalSelectableCount = computed(() => suggestionCount.value + resultCount.value) 551 541 552 - /** Unified selected index: negative for suggestions, 0+ for packages */ 553 - const unifiedSelectedIndex = shallowRef(0) 554 - const userHasNavigated = shallowRef(false) 555 - 556 - /** Convert unified index to suggestion index (0-based) or null */ 557 - function toSuggestionIndex(unified: number): number | null { 558 - if (unified < 0 && unified >= -suggestionCount.value) { 559 - return suggestionCount.value + unified 560 - } 561 - return null 562 - } 563 - 564 - /** Convert unified index to package index or null */ 565 - function toPackageIndex(unified: number): number | null { 566 - if (unified >= 0 && unified < resultCount.value) { 567 - return unified 568 - } 569 - return null 570 - } 571 - 572 - /** Clamp unified index to valid range */ 573 - function clampUnifiedIndex(next: number): number { 574 - const min = -suggestionCount.value 575 - const max = Math.max(0, resultCount.value - 1) 576 - if (totalSelectableCount.value <= 0) return 0 577 - return Math.max(min, Math.min(max, next)) 542 + /** 543 + * Get all focusable result elements in DOM order (suggestions first, then packages) 544 + */ 545 + function getFocusableElements(): HTMLElement[] { 546 + const suggestions = Array.from( 547 + document.querySelectorAll<HTMLElement>('[data-suggestion-index]'), 548 + ).sort((a, b) => { 549 + const aIdx = Number.parseInt(a.dataset.suggestionIndex ?? '0', 10) 550 + const bIdx = Number.parseInt(b.dataset.suggestionIndex ?? '0', 10) 551 + return aIdx - bIdx 552 + }) 553 + const packages = Array.from(document.querySelectorAll<HTMLElement>('[data-result-index]')).sort( 554 + (a, b) => { 555 + const aIdx = Number.parseInt(a.dataset.resultIndex ?? '0', 10) 556 + const bIdx = Number.parseInt(b.dataset.resultIndex ?? '0', 10) 557 + return aIdx - bIdx 558 + }, 559 + ) 560 + return [...suggestions, ...packages] 578 561 } 579 562 580 - // Keep legacy selectedIndex in sync for PackageList 581 - watch(unifiedSelectedIndex, unified => { 582 - const pkgIndex = toPackageIndex(unified) 583 - selectedIndex.value = pkgIndex ?? -1 584 - }) 585 - 586 - // Initialize selection to exact match when results load 587 - watch( 588 - [visibleResults, validatedSuggestions, exactMatchType], 589 - () => { 590 - if (userHasNavigated.value) { 591 - unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value) 592 - return 593 - } 594 - 595 - if (exactMatchType.value === 'package') { 596 - // Find the exact match package index 597 - const q = query.value.trim().toLowerCase() 598 - const idx = 599 - visibleResults.value?.objects.findIndex(r => r.package.name.toLowerCase() === q) ?? -1 600 - if (idx >= 0) { 601 - unifiedSelectedIndex.value = idx 602 - return 603 - } 604 - } 605 - if (exactMatchType.value === 'org') { 606 - // Select the org suggestion 607 - const orgIdx = validatedSuggestions.value.findIndex(s => s.type === 'org') 608 - if (orgIdx >= 0) { 609 - unifiedSelectedIndex.value = -(suggestionCount.value - orgIdx) 610 - return 611 - } 612 - } 613 - if (exactMatchType.value === 'user') { 614 - // Select the user suggestion 615 - const userIdx = validatedSuggestions.value.findIndex(s => s.type === 'user') 616 - if (userIdx >= 0) { 617 - unifiedSelectedIndex.value = -(suggestionCount.value - userIdx) 618 - return 619 - } 620 - } 621 - // Default to first item (first suggestion if any, else first package) 622 - unifiedSelectedIndex.value = suggestionCount.value > 0 ? -suggestionCount.value : 0 623 - }, 624 - { immediate: true }, 625 - ) 626 - 627 - // Reset selection and navigation flag when query changes 628 - watch(query, () => { 629 - userHasNavigated.value = false 630 - // Will be re-initialized by the watch above when results load 631 - unifiedSelectedIndex.value = 0 632 - }) 633 - 634 - function scrollToSelectedItem() { 635 - const pkgIndex = toPackageIndex(unifiedSelectedIndex.value) 636 - if (pkgIndex !== null) { 637 - packageListRef.value?.scrollToIndex(pkgIndex) 638 - } 563 + /** 564 + * Focus an element and scroll it into view 565 + */ 566 + function focusElement(el: HTMLElement) { 567 + el.focus() 568 + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) 639 569 } 640 570 641 571 function handleResultsKeydown(e: KeyboardEvent) { 642 572 if (totalSelectableCount.value <= 0) return 643 573 574 + const elements = getFocusableElements() 575 + if (elements.length === 0) return 576 + 577 + const currentIndex = elements.findIndex(el => el === document.activeElement) 578 + 644 579 if (e.key === 'ArrowDown') { 645 580 e.preventDefault() 646 - userHasNavigated.value = true 647 - unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value + 1) 648 - scrollToSelectedItem() 581 + const nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, elements.length - 1) 582 + const el = elements[nextIndex] 583 + if (el) focusElement(el) 649 584 return 650 585 } 651 586 652 587 if (e.key === 'ArrowUp') { 653 588 e.preventDefault() 654 - userHasNavigated.value = true 655 - unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value - 1) 656 - scrollToSelectedItem() 589 + const nextIndex = currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0) 590 + const el = elements[nextIndex] 591 + if (el) focusElement(el) 657 592 return 658 593 } 659 594 660 595 if (e.key === 'Enter') { 661 - if (!resultsMatchQuery.value) return 662 - 663 - const suggIdx = toSuggestionIndex(unifiedSelectedIndex.value) 664 - const pkgIdx = toPackageIndex(unifiedSelectedIndex.value) 665 - 666 - if (suggIdx !== null) { 667 - const el = document.querySelector<HTMLElement>(`[data-suggestion-index="${suggIdx}"]`) 668 - if (el) { 669 - e.preventDefault() 670 - el.click() 671 - } 672 - } else if (pkgIdx !== null) { 673 - const el = document.querySelector<HTMLElement>(`[data-result-index="${pkgIdx}"]`) 674 - if (el) { 596 + // Browser handles Enter on focused links naturally, but handle for non-link elements 597 + if (document.activeElement && elements.includes(document.activeElement as HTMLElement)) { 598 + const el = document.activeElement as HTMLElement 599 + // Only prevent default and click if it's not already a link (links handle Enter natively) 600 + if (el.tagName !== 'A') { 675 601 e.preventDefault() 676 602 el.click() 677 603 } ··· 681 607 682 608 onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown) 683 609 684 - function handleSuggestionSelect(index: number) { 685 - // Convert suggestion index to unified index 686 - unifiedSelectedIndex.value = -(suggestionCount.value - index) 687 - } 688 - 689 - function handlePackageSelect(index: number) { 690 - if (index < 0) return 691 - unifiedSelectedIndex.value = index 692 - } 693 - 694 610 useSeoMeta({ 695 611 title: () => (query.value ? `Search: ${query.value} - npmx` : 'Search Packages - npmx'), 696 612 }) ··· 719 635 :type="suggestion.type" 720 636 :name="suggestion.name" 721 637 :index="idx" 722 - :selected="toSuggestionIndex(unifiedSelectedIndex) === idx" 723 638 :is-exact-match=" 724 639 (exactMatchType === 'org' && suggestion.type === 'org') || 725 640 (exactMatchType === 'user' && suggestion.type === 'user') 726 641 " 727 - @focus="handleSuggestionSelect" 728 642 /> 729 643 </div> 730 644 ··· 819 733 :type="suggestion.type" 820 734 :name="suggestion.name" 821 735 :index="idx" 822 - :selected="toSuggestionIndex(unifiedSelectedIndex) === idx" 823 736 :is-exact-match=" 824 737 (exactMatchType === 'org' && suggestion.type === 'org') || 825 738 (exactMatchType === 'user' && suggestion.type === 'user') 826 739 " 827 - @focus="handleSuggestionSelect" 828 740 /> 829 741 </div> 830 742 ··· 847 759 v-if="displayResults.length > 0" 848 760 ref="packageListRef" 849 761 :results="displayResults" 850 - :selected-index="selectedIndex" 851 762 :search-query="query" 852 763 heading-level="h2" 853 764 show-publisher ··· 862 773 :current-page="currentPage" 863 774 @load-more="loadMore" 864 775 @page-change="handlePageChange" 865 - @select="handlePackageSelect" 866 776 @click-keyword="toggleKeyword" 867 777 /> 868 778