From f691b6e8f8d59dceaa1e33050484fb11d774a970 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 11 Nov 2026 07:44:11 -0500 Subject: [PATCH 1/2] refactor(ui): extract a package typeahead from the compare selector The compare page's search box becomes PackageTypeahead so the collection editor can reuse it. No behavior change. --- app/components/Compare/PackageSelector.vue | 259 +++--------------- app/components/Package/Typeahead.vue | 222 +++++++++++++++ i18n/locales/ar.json | 1 - i18n/locales/az-AZ.json | 1 - i18n/locales/bg-BG.json | 1 - i18n/locales/bn-IN.json | 1 - i18n/locales/cs-CZ.json | 1 - i18n/locales/de.json | 1 - i18n/locales/en.json | 1 - i18n/locales/es.json | 1 - i18n/locales/fr-FR.json | 1 - i18n/locales/hi-IN.json | 1 - i18n/locales/hu-HU.json | 1 - i18n/locales/id-ID.json | 1 - i18n/locales/it-IT.json | 1 - i18n/locales/ja-JP.json | 1 - i18n/locales/kn-IN.json | 1 - i18n/locales/ko-KR.json | 1 - i18n/locales/mr-IN.json | 1 - i18n/locales/nb-NO.json | 1 - i18n/locales/ne-NP.json | 1 - i18n/locales/nl.json | 1 - i18n/locales/pl-PL.json | 1 - i18n/locales/pt-BR.json | 1 - i18n/locales/pt-PT.json | 1 - i18n/locales/ro-RO.json | 1 - i18n/locales/ru-RU.json | 5 +- i18n/locales/sr-Latn-RS.json | 1 - i18n/locales/ta-IN.json | 1 - i18n/locales/te-IN.json | 1 - i18n/locales/tr-TR.json | 1 - i18n/locales/uk-UA.json | 8 +- i18n/locales/vi-VN.json | 1 - i18n/locales/zh-CN.json | 1 - i18n/locales/zh-TW.json | 1 - i18n/schema.json | 3 - test/nuxt/a11y.spec.ts | 11 + .../nuxt/components/Package/Typeahead.spec.ts | 60 ++++ .../compare/PackageSelector.spec.ts | 24 +- 39 files changed, 349 insertions(+), 274 deletions(-) create mode 100644 app/components/Package/Typeahead.vue create mode 100644 test/nuxt/components/Package/Typeahead.spec.ts diff --git a/app/components/Compare/PackageSelector.vue b/app/components/Compare/PackageSelector.vue index 2cad026112..d333674000 100644 --- a/app/components/Compare/PackageSelector.vue +++ b/app/components/Compare/PackageSelector.vue @@ -8,20 +8,7 @@ const props = defineProps<{ const maxPackages = computed(() => props.max ?? MAX_PACKAGE_SELECTION) -// Input state const inputValue = shallowRef('') -const isInputFocused = shallowRef(false) - -// Keyboard navigation state -const highlightedIndex = shallowRef(-1) -const listRef = useTemplateRef('listRef') -const PAGE_JUMP = 5 - -// Use the shared search composable (supports both npm and Algolia providers) -const { searchProvider } = useSearchProvider() -const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 }) - -const isSearching = computed(() => status.value === 'pending') // Trigger strings for "What Would James Do?" typeahead Easter egg // Intentionally not localized @@ -46,37 +33,17 @@ const showNoDependencyOption = computed(() => { return EASTER_EGG_TRIGGERS.has(input) }) -// Filter out already selected packages -const selectedBaseNames = computed(() => new Set(packages.value.map(p => parsePackageSpec(p).name))) - -const filteredResults = computed(() => { - if (!searchData.value?.objects) return [] - return searchData.value.objects - .map(o => ({ - name: o.package.name, - description: o.package.description, - })) - .filter(r => !selectedBaseNames.value.has(r.name)) -}) - -// Unified list of navigable items for keyboard navigation -const navigableItems = computed(() => { - const items: { type: 'no-dependency' | 'package'; name: string }[] = [] - if (showNoDependencyOption.value) { - items.push({ type: 'no-dependency', name: NO_DEPENDENCY_ID }) - } - for (const r of filteredResults.value) { - items.push({ type: 'package', name: r.name }) - } - return items -}) +const leadingOptions = computed(() => + showNoDependencyOption.value + ? [{ value: NO_DEPENDENCY_ID, label: $t('compare.no_dependency.add_column') }] + : [], +) -const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0)) +// Filter out already selected packages +const selectedBaseNames = computed(() => packages.value.map(p => parsePackageSpec(p).name)) const numberFormatter = useNumberFormatter() -const keyboardShortcuts = useKeyboardShortcuts() - function addPackage(spec: string) { if (packages.value.length >= maxPackages.value) return const trimmedSpec = spec.trim() @@ -94,111 +61,21 @@ function addPackage(spec: string) { packages.value = [...packages.value, trimmedSpec] } inputValue.value = '' - highlightedIndex.value = -1 } function removePackage(name: string) { packages.value = packages.value.filter(p => p !== name) } -function handleKeydown(e: KeyboardEvent) { - if (!keyboardShortcuts.value) { +// Fallback: easter egg, exact match, or manual version spec entry (e.g. lodash@3.10.1) +function handleSubmit(query: string, exactMatch: boolean) { + if (showNoDependencyOption.value) { + addPackage(NO_DEPENDENCY_ID) return } - - const items = navigableItems.value - const count = items.length - - switch (e.key) { - case 'ArrowDown': - e.preventDefault() - if (count === 0) return - if (highlightedIndex.value < count - 1) { - highlightedIndex.value++ - } else { - highlightedIndex.value = 0 - } - break - - case 'ArrowUp': - e.preventDefault() - if (count === 0) return - if (highlightedIndex.value > 0) { - highlightedIndex.value-- - } else { - highlightedIndex.value = count - 1 - } - break - - case 'PageDown': - e.preventDefault() - if (count === 0) return - if (highlightedIndex.value === -1) { - highlightedIndex.value = Math.min(PAGE_JUMP - 1, count - 1) - } else { - highlightedIndex.value = Math.min(highlightedIndex.value + PAGE_JUMP, count - 1) - } - break - - case 'PageUp': - e.preventDefault() - if (count === 0) return - highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0) - break - - case 'Enter': { - const inputValueTrim = inputValue.value.trim() - if (!inputValueTrim) return - - e.preventDefault() - - // If an item is highlighted, select it - if (highlightedIndex.value >= 0 && highlightedIndex.value < count) { - addPackage(items[highlightedIndex.value]!.name) - return - } - - // Fallback: exact match, easter egg, or manual version spec entry (e.g. lodash@3.10.1) - if (showNoDependencyOption.value) { - addPackage(NO_DEPENDENCY_ID) - } else { - const parsed = parsePackageSpec(inputValueTrim) - const hasMatch = filteredResults.value.find(r => r.name === parsed.name) - if (hasMatch || parsed.version) { - addPackage(inputValueTrim) - } - } - break - } - - case 'Escape': - inputValue.value = '' - highlightedIndex.value = -1 - break - } + if (exactMatch || parsePackageSpec(query).version) addPackage(query) } -// Reset highlight when user types -watch(inputValue, () => { - highlightedIndex.value = -1 -}) - -// Scroll highlighted item into view -watch(highlightedIndex, index => { - if (index >= 0 && listRef.value) { - const items = listRef.value.querySelectorAll('[data-navigable]') - const item = items[index] as HTMLElement | undefined - item?.scrollIntoView({ block: 'nearest' }) - } -}) - -const containerRef = useTemplateRef('containerRef') - -onClickOutside(containerRef, () => { - isInputFocused.value = false - highlightedIndex.value = -1 -}) - const versionHintId = useId() @@ -235,93 +112,33 @@ const versionHintId = useId() -
-
- - - / + + + + +type TypeaheadOption = { value: string; label: string } + +const query = defineModel('query', { default: '' }) + +const props = defineProps<{ + inputId: string + /** Accessible label; visually hidden */ + label: string + placeholder?: string + describedby?: string + /** Package names to leave out of results, e.g. ones already chosen */ + exclude?: readonly string[] + /** Shown above search results and navigable with them; content via the `option` slot */ + leadingOptions?: readonly TypeaheadOption[] +}>() + +const emit = defineEmits<{ + /** A package (or leading option value) was picked from the list */ + select: [value: string] + /** Enter with nothing highlighted; the parent decides what free text means */ + submit: [query: string, exactMatch: boolean] +}>() + +defineSlots<{ + option?: (props: { option: TypeaheadOption }) => unknown +}>() + +const isInputFocused = shallowRef(false) +const highlightedIndex = shallowRef(-1) +const listRef = useTemplateRef('listRef') +const PAGE_JUMP = 5 + +const { searchProvider } = useSearchProvider() +const { data: searchData, status } = useSearch(query, searchProvider, { size: 15 }) +const isSearching = computed(() => status.value === 'pending') + +const excluded = computed(() => new Set(props.exclude ?? [])) +const leading = computed(() => props.leadingOptions ?? []) + +const filteredResults = computed(() => { + if (!searchData.value?.objects) return [] + return searchData.value.objects + .map(o => ({ name: o.package.name, description: o.package.description })) + .filter(r => !excluded.value.has(r.name)) +}) + +const navigableValues = computed(() => [ + ...leading.value.map(o => o.value), + ...filteredResults.value.map(r => r.name), +]) + +// A fast "zod⏎" shouldn't be judged against the previous query's results. +async function submitWhenSearched(text: string) { + await nextTick() + if (isSearching.value) { + await new Promise(resolve => { + const stop = watch(isSearching, searching => { + if (searching) return + stop() + resolve() + }) + }) + } + // Typing on while it waited makes this submission stale + if (text !== query.value.trim()) return + emit( + 'submit', + text, + filteredResults.value.some(r => r.name === text), + ) +} + +const keyboardShortcuts = useKeyboardShortcuts() + +function handleKeydown(e: KeyboardEvent) { + // List navigation follows the shortcuts setting; Enter and Escape always work + if (!keyboardShortcuts.value && e.key !== 'Enter' && e.key !== 'Escape') return + + const values = navigableValues.value + const count = values.length + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + if (count === 0) return + highlightedIndex.value = highlightedIndex.value < count - 1 ? highlightedIndex.value + 1 : 0 + break + + case 'ArrowUp': + e.preventDefault() + if (count === 0) return + highlightedIndex.value = highlightedIndex.value > 0 ? highlightedIndex.value - 1 : count - 1 + break + + case 'PageDown': + e.preventDefault() + if (count === 0) return + highlightedIndex.value = + highlightedIndex.value === -1 + ? Math.min(PAGE_JUMP - 1, count - 1) + : Math.min(highlightedIndex.value + PAGE_JUMP, count - 1) + break + + case 'PageUp': + e.preventDefault() + if (count === 0) return + highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0) + break + + case 'Enter': { + const trimmed = query.value.trim() + if (!trimmed) return + e.preventDefault() + if (highlightedIndex.value >= 0 && highlightedIndex.value < count) { + emit('select', values[highlightedIndex.value]!) + return + } + void submitWhenSearched(trimmed) + break + } + + case 'Escape': + query.value = '' + highlightedIndex.value = -1 + break + } +} + +watch(query, () => { + highlightedIndex.value = -1 +}) + +watch(highlightedIndex, index => { + if (index >= 0 && listRef.value) { + const items = listRef.value.querySelectorAll('[data-navigable]') + ;(items[index] as HTMLElement | undefined)?.scrollIntoView({ block: 'nearest' }) + } +}) + +const containerRef = useTemplateRef('containerRef') +onClickOutside(containerRef, () => { + isInputFocused.value = false + highlightedIndex.value = -1 +}) + + + diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index 5c0d7b0c4d..ff4cdc02fb 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -899,7 +899,6 @@ "search_label": "ابحث عن حزم", "search_first": "ابحث عن حزمة…", "search_add": "أضف حزمة أخرى…", - "searching": "جارٍ البحث…", "remove_package": "إزالة {package}", "packages_selected": "{count}/{max} حزمة محددة.", "add_hint": "أضف حزمتين على الأقل للمقارنة." diff --git a/i18n/locales/az-AZ.json b/i18n/locales/az-AZ.json index c799007e26..40c6fa2d4d 100644 --- a/i18n/locales/az-AZ.json +++ b/i18n/locales/az-AZ.json @@ -1026,7 +1026,6 @@ "search_label": "Paketləri axtar", "search_first": "Paket axtarın...", "search_add": "Başqa paket əlavə edin...", - "searching": "Axtarılır...", "remove_package": "{package} sil", "packages_selected": "{count}/{max} paket seçilib.", "add_hint": "Müqayisə üçün ən azı 2 paket əlavə edin." diff --git a/i18n/locales/bg-BG.json b/i18n/locales/bg-BG.json index 2d5fcb73b6..33dbecef04 100644 --- a/i18n/locales/bg-BG.json +++ b/i18n/locales/bg-BG.json @@ -977,7 +977,6 @@ "search_label": "Търсене на пакети", "search_first": "Търсене на пакет...", "search_add": "Добавяне на още пакет...", - "searching": "Търсене...", "remove_package": "Премахване на {package}", "packages_selected": "{count}/{max} избрани пакета.", "add_hint": "Добавете поне 2 пакета за сравнение." diff --git a/i18n/locales/bn-IN.json b/i18n/locales/bn-IN.json index a5a909bfb4..03bba2694f 100644 --- a/i18n/locales/bn-IN.json +++ b/i18n/locales/bn-IN.json @@ -1403,7 +1403,6 @@ "search_label": "প্যাকেজ অনুসন্ধান করুন", "search_first": "প্যাকেজ অনুসন্ধান করুন...", "search_add": "আরেকটি প্যাকেজ যোগ করুন...", - "searching": "অনুসন্ধান করা হচ্ছে...", "remove_package": "{package} সরান", "packages_selected": "{count}/{max}টি প্যাকেজ নির্বাচিত।", "add_hint": "তুলনা করার জন্য কমপক্ষে ২টি প্যাকেজ যোগ করুন।" diff --git a/i18n/locales/cs-CZ.json b/i18n/locales/cs-CZ.json index cb2cbc21c6..ca0275a9de 100644 --- a/i18n/locales/cs-CZ.json +++ b/i18n/locales/cs-CZ.json @@ -1516,7 +1516,6 @@ "search_label": "Vyhledat balíčky", "search_first": "Vyhledat balíček...", "search_add": "Přidat další balíček...", - "searching": "Vyhledávání...", "remove_package": "Odebrat {package}", "packages_selected": "Vybrané balíčky: {count}/{max}.", "add_hint": "Přidejte alespoň 2 balíčky ke srovnání.", diff --git a/i18n/locales/de.json b/i18n/locales/de.json index f78e00532f..d23e52cd63 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -1290,7 +1290,6 @@ "search_label": "Nach Paketen suchen", "search_first": "Nach einem Paket suchen...", "search_add": "Weiteres Paket hinzufügen...", - "searching": "Suche läuft...", "remove_package": "{package} entfernen", "packages_selected": "{count}/{max} Pakete ausgewählt.", "add_hint": "Füge mindestens 2 Pakete zum Vergleichen hinzu." diff --git a/i18n/locales/en.json b/i18n/locales/en.json index acb9f3e050..38ba8589fe 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1527,7 +1527,6 @@ "search_label": "Search for packages", "search_first": "Search for a package...", "search_add": "Add another package...", - "searching": "Searching...", "remove_package": "Remove {package}", "packages_selected": "{count}/{max} packages selected.", "add_hint": "Add at least 2 packages to compare.", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 09fdd5c99b..faa8e74c41 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -1404,7 +1404,6 @@ "search_label": "Buscar paquetes", "search_first": "Buscar un paquete...", "search_add": "Añadir otro paquete...", - "searching": "Buscando...", "remove_package": "Eliminar {package}", "packages_selected": "{count}/{max} paquetes seleccionados.", "add_hint": "Añade al menos 2 paquetes para comparar." diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 909d3f375a..ef69005a37 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1325,7 +1325,6 @@ "search_label": "Rechercher des paquets", "search_first": "Rechercher un paquet...", "search_add": "Ajouter un autre paquet...", - "searching": "Recherche...", "remove_package": "Supprimer {package}", "packages_selected": "{count}/{max} paquets sélectionnés.", "add_hint": "Ajoutez au moins 2 paquets à comparer." diff --git a/i18n/locales/hi-IN.json b/i18n/locales/hi-IN.json index 76a9dace66..86371f704c 100644 --- a/i18n/locales/hi-IN.json +++ b/i18n/locales/hi-IN.json @@ -1084,7 +1084,6 @@ "search_label": "पैकेज खोजें", "search_first": "पैकेज खोजें...", "search_add": "एक और पैकेज जोड़ें...", - "searching": "खोज रहे हैं...", "remove_package": "{package} हटाएं", "packages_selected": "{count}/{max} पैकेज चुने गए।", "add_hint": "तुलना करने के लिए कम से कम 2 पैकेज जोड़ें।" diff --git a/i18n/locales/hu-HU.json b/i18n/locales/hu-HU.json index ac0ab2a6b3..fd3da40eff 100644 --- a/i18n/locales/hu-HU.json +++ b/i18n/locales/hu-HU.json @@ -977,7 +977,6 @@ "search_label": "Csomagok keresése", "search_first": "Csomag keresése...", "search_add": "Egy másik csomag hozzáadása...", - "searching": "Keresés...", "remove_package": "{package} eltávolítása", "packages_selected": "{count}/{max} csomag kiválasztva.", "add_hint": "Adj hozzá legalább 2 csomagot az összehasonlításhoz." diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 9d5cbe99c4..a28bf3fea5 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -1105,7 +1105,6 @@ "search_label": "Cari paket", "search_first": "Cari paket...", "search_add": "Tambah paket lain...", - "searching": "Mencari...", "remove_package": "Hapus {package}", "packages_selected": "{count}/{max} paket dipilih.", "add_hint": "Tambah setidaknya 2 paket untuk dibandingkan." diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 9a98652cff..830dc1d1c5 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -1328,7 +1328,6 @@ "search_label": "Cerca pacchetti", "search_first": "Cerca un pacchetto...", "search_add": "Aggiungi un altro pacchetto...", - "searching": "Cercando...", "remove_package": "Rimuovi {package}", "packages_selected": "{count}/{max} pacchetti selezionati.", "add_hint": "Aggiungi almeno 2 pacchetti da confrontare." diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 1e69b421f6..e4bfde2083 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -1396,7 +1396,6 @@ "search_label": "パッケージを検索", "search_first": "パッケージを検索...", "search_add": "パッケージを追加...", - "searching": "検索中...", "remove_package": "{package} を削除", "packages_selected": "{count}/{max} パッケージ選択中。", "add_hint": "比較するには少なくとも2つのパッケージを追加してください。" diff --git a/i18n/locales/kn-IN.json b/i18n/locales/kn-IN.json index a515afb1d6..6efe73df65 100644 --- a/i18n/locales/kn-IN.json +++ b/i18n/locales/kn-IN.json @@ -766,7 +766,6 @@ "search_label": "ಪ್ಯಾಕೇಜ್ ಹುಡುಕಿ", "search_first": "ಪ್ಯಾಕೇಜ್ ಹುಡುಕಿ...", "search_add": "ಇನ್ನೊಂದು ಪ್ಯಾಕೇಜ್ ಸೇರಿಸಿ...", - "searching": "ಹುಡುಕಲಾಗುತ್ತಿದೆ...", "remove_package": "{package} ತೆಗೆದುಹಾಕಿ", "packages_selected": "{count}/{max} ಪ್ಯಾಕೇಜ್‌ಗಳು ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ.", "add_hint": "ಹೋಲಿಸಲು ಕನಿಷ್ಠ 2 ಪ್ಯಾಕೇಜ್‌ಗಳನ್ನು ಸೇರಿಸಿ." diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 3c722e893f..87eadc05b2 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -1429,7 +1429,6 @@ "search_label": "패키지 검색", "search_first": "패키지 검색...", "search_add": "다른 패키지 추가...", - "searching": "검색 중...", "remove_package": "{package} 제거", "packages_selected": "{count}/{max}개 패키지 선택됨.", "add_hint": "비교하려면 패키지를 2개 이상 추가하세요." diff --git a/i18n/locales/mr-IN.json b/i18n/locales/mr-IN.json index 644e78ae1e..80d9a26a0c 100644 --- a/i18n/locales/mr-IN.json +++ b/i18n/locales/mr-IN.json @@ -1055,7 +1055,6 @@ "search_label": "पॅकेजेस शोधा", "search_first": "एखादे पॅकेज शोधा...", "search_add": "आणखी एक पॅकेज जोडा...", - "searching": "शोधत आहे...", "remove_package": "{package} काढा", "packages_selected": "{count}/{max} पॅकेजेस निवडली.", "add_hint": "तुलनेसाठी किमान 2 पॅकेजेस जोडा." diff --git a/i18n/locales/nb-NO.json b/i18n/locales/nb-NO.json index a6092e4926..55e6521922 100644 --- a/i18n/locales/nb-NO.json +++ b/i18n/locales/nb-NO.json @@ -1333,7 +1333,6 @@ "search_label": "Søk etter pakker", "search_first": "Søk etter en pakke...", "search_add": "Legg til en annen pakke...", - "searching": "Søker...", "remove_package": "Fjern {package}", "packages_selected": "{count}/{max} pakker valgt.", "add_hint": "Legg til minst 2 pakker for å sammenligne." diff --git a/i18n/locales/ne-NP.json b/i18n/locales/ne-NP.json index 73914e2843..3075522323 100644 --- a/i18n/locales/ne-NP.json +++ b/i18n/locales/ne-NP.json @@ -1480,7 +1480,6 @@ "search_label": "प्याकेज खोज्नुहोस्", "search_first": "प्याकेज खोज्नुहोस्...", "search_add": "अर्को प्याकेज थप्नुहोस्...", - "searching": "खोजिँदैछ...", "remove_package": "{package} हटाउनुहोस्", "packages_selected": "{count}/{max} प्याकेज चयन गरिएका छन्।", "add_hint": "तुलना गर्न कम्तीमा 2 प्याकेज थप्नुहोस्।" diff --git a/i18n/locales/nl.json b/i18n/locales/nl.json index 11a1d14cab..0cf2cbaf3b 100644 --- a/i18n/locales/nl.json +++ b/i18n/locales/nl.json @@ -1510,7 +1510,6 @@ "search_label": "Zoek voor pakketten", "search_first": "Zoek voor een pakket...", "search_add": "Voeg nog een pakket toe...", - "searching": "Zoeken...", "remove_package": "Verwijder {package}", "packages_selected": "{count}/{max} pakketten geselecteerd.", "add_hint": "Voeg tenminste 2 pakketten toe om te vergelijken" diff --git a/i18n/locales/pl-PL.json b/i18n/locales/pl-PL.json index 61c2f21957..c746a806da 100644 --- a/i18n/locales/pl-PL.json +++ b/i18n/locales/pl-PL.json @@ -1042,7 +1042,6 @@ "search_label": "Szukaj pakietów", "search_first": "Wyszukaj pakiet...", "search_add": "Dodaj kolejny pakiet...", - "searching": "Wyszukiwanie...", "remove_package": "Usuń {package}", "packages_selected": "Wybrano pakiety: {count}/{max}.", "add_hint": "Dodaj co najmniej 2 pakiety do porównania." diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 50f35f2820..2204cf6372 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -1251,7 +1251,6 @@ "search_label": "Pesquisar por pacotes", "search_first": "Pesquisar por um pacote...", "search_add": "Adicionar outro pacote...", - "searching": "Pesquisando...", "remove_package": "Remover {package}", "packages_selected": "{count}/{max} pacotes selecionados.", "add_hint": "Adicione pelo menos 2 pacotes para comparar." diff --git a/i18n/locales/pt-PT.json b/i18n/locales/pt-PT.json index b32593ec04..cd1b67653f 100644 --- a/i18n/locales/pt-PT.json +++ b/i18n/locales/pt-PT.json @@ -1265,7 +1265,6 @@ "search_label": "Pesquisar por pacotes", "search_first": "Pesquisar por um pacote...", "search_add": "Adicionar outro pacote...", - "searching": "A pesquisar...", "remove_package": "Remover {package}", "packages_selected": "{count}/{max} pacotes selecionados.", "add_hint": "Adiciona pelo menos 2 pacotes para comparar." diff --git a/i18n/locales/ro-RO.json b/i18n/locales/ro-RO.json index 1d94ba46da..705b5a3342 100644 --- a/i18n/locales/ro-RO.json +++ b/i18n/locales/ro-RO.json @@ -1276,7 +1276,6 @@ "search_label": "Căută pachete", "search_first": "Căută pachetul...", "search_add": "Adaugă alt pachet...", - "searching": "Căutare...", "remove_package": "Șterge {package}", "packages_selected": "{count}/{max} pachete selectate.", "add_hint": "Adaugă cel puțin 2 pachete pentru a le compara." diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index f3155e9730..d497057b30 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -1485,7 +1485,6 @@ "search_label": "Поиск пакетов", "search_first": "Найти пакет...", "search_add": "Добавить ещё один пакет...", - "searching": "Поиск...", "remove_package": "Удалить {package}", "packages_selected": "Выбрано пакетов: {count}/{max}.", "add_hint": "Добавьте как минимум 2 пакета для сравнения." @@ -1921,9 +1920,9 @@ "p2": "Пожалуйста, попробуйте позже или {viewon}" }, "version_unavailable": "Запрошенная версия недоступна.", + "copy_as_markdown": "Скопировать список изменений как Markdown", "ungh": { "rate_limit": "К сожалению, достигнут лимит запросов GitHub. Попробуйте снова через некоторое время." - }, - "copy_as_markdown": "Скопировать список изменений как Markdown" + } } } diff --git a/i18n/locales/sr-Latn-RS.json b/i18n/locales/sr-Latn-RS.json index 67a838fee8..2de498d615 100644 --- a/i18n/locales/sr-Latn-RS.json +++ b/i18n/locales/sr-Latn-RS.json @@ -1101,7 +1101,6 @@ "search_label": "Pretražite pakete", "search_first": "Pretražite paket...", "search_add": "Dodajte još jedan paket...", - "searching": "Pretraživanje...", "remove_package": "Uklonite {package}", "packages_selected": "{count}/{max} paketa izabrano.", "add_hint": "Dodajte najmanje 2 paketa za poređenje." diff --git a/i18n/locales/ta-IN.json b/i18n/locales/ta-IN.json index a8aa11d21f..8ed033021f 100644 --- a/i18n/locales/ta-IN.json +++ b/i18n/locales/ta-IN.json @@ -867,7 +867,6 @@ "search_label": "தொகுப்புகளைத் தேடு", "search_first": "ஒரு தொகுப்பைத் தேடு...", "search_add": "மற்றொரு தொகுப்பைச் சேர்...", - "searching": "தேடுகிறது...", "remove_package": "{package} ஐ நீக்கு", "packages_selected": "{count}/{max} தொகுப்புகள் தேர்ந்தெடுக்கப்பட்டன.", "add_hint": "ஒப்பிட குறைந்தது 2 தொகுப்புகளைச் சேர்க்கவும்." diff --git a/i18n/locales/te-IN.json b/i18n/locales/te-IN.json index 2311289bc9..ecbf9d6b91 100644 --- a/i18n/locales/te-IN.json +++ b/i18n/locales/te-IN.json @@ -766,7 +766,6 @@ "search_label": "ప్యాకేజ్‌ను శోధించండి", "search_first": "ప్యాకేజ్‌ను శోధించండి...", "search_add": "మరో ప్యాకేజ్‌ను జోడించండి...", - "searching": "శోధిస్తున్నారు...", "remove_package": "{package} ను తీసివేయండి", "packages_selected": "{count}/{max} ప్యాకేజ్‌లు ఎంచుకోబడ్డాయి.", "add_hint": "పోల్చడానికి కనీసం 2 ప్యాకేజ్‌లను జోడించండి." diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 99e74aca52..910d3a9280 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -1284,7 +1284,6 @@ "search_label": "Paket ara", "search_first": "İlk paketi ara...", "search_add": "Paket ekle...", - "searching": "Aranıyor...", "remove_package": "{package} paketini kaldır", "packages_selected": "{count} paket seçildi", "add_hint": "Karşılaştırmak için paket ekleyin" diff --git a/i18n/locales/uk-UA.json b/i18n/locales/uk-UA.json index 7746c0bb90..ace7795904 100644 --- a/i18n/locales/uk-UA.json +++ b/i18n/locales/uk-UA.json @@ -838,10 +838,6 @@ "sparkline_zoom_reset": { "title": "Скинути масштаб · двічі клацніть діаграму або натисніть Esc", "aria_label": "Скинути масштаб" - }, - "downloads_heatmap": { - "legend": {}, - "copy_alt": {} } }, "downloads": { @@ -1489,7 +1485,6 @@ "search_label": "Пошук пакетів", "search_first": "Знайти пакет...", "search_add": "Додати ще пакет...", - "searching": "Пошук...", "remove_package": "Видалити {package}", "packages_selected": "Вибрано {count}/{max} пакетів.", "add_hint": "Додайте щонайменше 2 пакети для порівняння." @@ -1925,7 +1920,6 @@ "p2": "Спробуйте пізніше або {viewon}" }, "version_unavailable": "Запитана версія недоступна.", - "copy_as_markdown": "Копіювати журнал змін як Markdown", - "ungh": {} + "copy_as_markdown": "Копіювати журнал змін як Markdown" } } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index 5293cc851c..8edfe9fba5 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -1101,7 +1101,6 @@ "search_label": "Tìm kiếm package", "search_first": "Tìm kiếm package...", "search_add": "Thêm một package khác...", - "searching": "Đang tìm kiếm...", "remove_package": "Gỡ {package}", "packages_selected": "Đã chọn {count}/{max} package.", "add_hint": "Thêm ít nhất 2 package để so sánh." diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 4e913672b8..b7f7597cba 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -1281,7 +1281,6 @@ "search_label": "搜索包", "search_first": "搜索包…", "search_add": "添加另一个包…", - "searching": "搜索中…", "remove_package": "移除 {package}", "packages_selected": "已选择 {count}/{max} 个包。", "add_hint": "至少添加 2 个包以进行比较。" diff --git a/i18n/locales/zh-TW.json b/i18n/locales/zh-TW.json index 5b02ba0487..5be3dea22f 100644 --- a/i18n/locales/zh-TW.json +++ b/i18n/locales/zh-TW.json @@ -1221,7 +1221,6 @@ "search_label": "搜尋套件", "search_first": "搜尋套件…", "search_add": "新增另一個套件…", - "searching": "搜尋中…", "remove_package": "移除 {package}", "packages_selected": "已選擇 {count}/{max} 個套件。", "add_hint": "至少新增 2 個套件以進行比較。" diff --git a/i18n/schema.json b/i18n/schema.json index fcc89367f2..1e659fe60e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -4585,9 +4585,6 @@ "search_add": { "type": "string" }, - "searching": { - "type": "string" - }, "remove_package": { "type": "string" }, diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index f17d325cd4..6628103bea 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -360,6 +360,7 @@ import FacetBarChart from '~/components/Compare/FacetBarChart.vue' import FacetScatterChart from '~/components/Compare/FacetScatterChart.vue' import PackageTimelineChart from '~/components/Package/TimelineChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' +import PackageTypeahead from '~/components/Package/Typeahead.vue' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import SizeDecrease from '~/components/Package/SizeDecrease.vue' import Likes from '~/components/Package/Likes.vue' @@ -3201,6 +3202,16 @@ describe('component accessibility audits', () => { }) }) + describe('PackageTypeahead', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(PackageTypeahead, { + props: { inputId: 'typeahead-a11y', label: 'Search for packages' }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('CompareFacetRow', () => { it('should have no accessibility violations with basic values', async () => { const component = await mountSuspended(CompareFacetRow, { diff --git a/test/nuxt/components/Package/Typeahead.spec.ts b/test/nuxt/components/Package/Typeahead.spec.ts new file mode 100644 index 0000000000..7b9252eb3e --- /dev/null +++ b/test/nuxt/components/Package/Typeahead.spec.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import PackageTypeahead from '~/components/Package/Typeahead.vue' + +const mockFetch = vi.fn() +vi.stubGlobal('$fetch', mockFetch) + +const mount = (props: Record = {}) => + mountSuspended(PackageTypeahead, { + props: { inputId: 'typeahead-test', label: 'Search', ...props }, + }) + +describe('PackageTypeahead', () => { + beforeEach(() => { + mockFetch.mockReset() + mockFetch.mockResolvedValue({ + objects: [ + { package: { name: 'zod', description: 'TypeScript-first schema validation' } }, + { package: { name: 'valibot', description: 'Modular schema library' } }, + ], + total: 2, + time: new Date().toISOString(), + }) + }) + + it('emits submit with an exact match flag when nothing is highlighted', async () => { + const component = await mount() + const input = component.find('input') + await input.setValue('zod') + await input.trigger('keydown', { key: 'Enter' }) + await vi.waitFor(() => expect(component.emitted('submit')).toEqual([['zod', true]])) + + mockFetch.mockResolvedValue({ objects: [], total: 0, time: new Date().toISOString() }) + await input.setValue('https://npmx.dev/collections/a/b') + await input.trigger('keydown', { key: 'Enter' }) + await vi.waitFor(() => + expect(component.emitted('submit')?.[1]).toEqual(['https://npmx.dev/collections/a/b', false]), + ) + }) + + it('emits select for the highlighted result, skipping excluded names', async () => { + const component = await mount({ exclude: ['zod'] }) + const input = component.find('input') + await input.setValue('schema') + await input.trigger('keydown', { key: 'ArrowDown' }) + await input.trigger('keydown', { key: 'Enter' }) + + expect(component.emitted('select')).toEqual([['valibot']]) + }) + + it('puts leading options before results', async () => { + const component = await mount({ leadingOptions: [{ value: 'special', label: 'Special' }] }) + const input = component.find('input') + await input.setValue('schema') + await input.trigger('keydown', { key: 'ArrowDown' }) + await input.trigger('keydown', { key: 'Enter' }) + + expect(component.emitted('select')).toEqual([['special']]) + }) +}) diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts index 64a060bd07..6c326efb95 100644 --- a/test/nuxt/components/compare/PackageSelector.spec.ts +++ b/test/nuxt/components/compare/PackageSelector.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { ref } from 'vue' import { mountSuspended } from '@nuxt/test-utils/runtime' import PackageSelector from '~/components/Compare/PackageSelector.vue' +import PackageTypeahead from '~/components/Package/Typeahead.vue' const mockFetch = vi.fn() vi.stubGlobal('$fetch', mockFetch) @@ -130,9 +131,10 @@ describe('PackageSelector', () => { await input.setValue('lodash') await input.trigger('keydown', { key: 'Enter' }) - const emitted = component.emitted('update:modelValue') - expect(emitted).toBeTruthy() - expect(emitted![0]![0]).toEqual(['lodash']) + // Enter waits for the in-flight search before judging an exact match + await vi.waitFor(() => + expect(component.emitted('update:modelValue')?.[0]?.[0]).toEqual(['lodash']), + ) }) it('adds "no dep" entry on Enter key', async () => { @@ -146,9 +148,9 @@ describe('PackageSelector', () => { await input.setValue('no dep') await input.trigger('keydown', { key: 'Enter' }) - const emitted = component.emitted('update:modelValue') - expect(emitted).toBeTruthy() - expect(emitted![0]![0]).toEqual(['__no_dependency__']) + await vi.waitFor(() => + expect(component.emitted('update:modelValue')?.[0]?.[0]).toEqual(['__no_dependency__']), + ) }) it('clears input after adding package', async () => { @@ -163,19 +165,23 @@ describe('PackageSelector', () => { await input.trigger('keydown', { key: 'Enter' }) // Input should be cleared - expect((input.element as HTMLInputElement).value).toBe('') + await vi.waitFor(() => expect((input.element as HTMLInputElement).value).toBe('')) }) it('does not add duplicate packages', async () => { const component = await mountSuspended(PackageSelector, { props: { - modelValue: ['lodash'], + modelValue: ['lodash@3.10.1'], }, }) const input = component.find('input') - await input.setValue('lodash') + await input.setValue('lodash@3.10.1') await input.trigger('keydown', { key: 'Enter' }) + // The submission is deferred until search settles + await vi.waitFor(() => + expect(component.findComponent(PackageTypeahead).emitted('submit')).toBeTruthy(), + ) const emitted = component.emitted('update:modelValue') // Should not emit since lodash is already selected From d8639ef7b7717e170d91d34e08fed6c9904b5c94 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 11 Nov 2026 07:44:11 -0500 Subject: [PATCH 2/2] fix(a11y): make the package typeahead an ARIA combobox The input keeps focus, arrow keys move an active option that screen readers announce, and tabbing away closes the list. Before, each option was its own tab stop and the list stayed open after losing focus. --- app/components/Package/Typeahead.vue | 112 +++++++++++------- test/e2e/interactions.spec.ts | 2 +- test/nuxt/a11y.spec.ts | 16 +++ .../nuxt/components/Package/Typeahead.spec.ts | 13 ++ 4 files changed, 98 insertions(+), 45 deletions(-) diff --git a/app/components/Package/Typeahead.vue b/app/components/Package/Typeahead.vue index 0f086f783a..815821920a 100644 --- a/app/components/Package/Typeahead.vue +++ b/app/components/Package/Typeahead.vue @@ -71,12 +71,9 @@ async function submitWhenSearched(text: string) { ) } -const keyboardShortcuts = useKeyboardShortcuts() - +// Not gated by the keyboard shortcuts setting: these are the combobox's own keys, and without +// them the options aren't reachable by keyboard function handleKeydown(e: KeyboardEvent) { - // List navigation follows the shortcuts setting; Enter and Escape always work - if (!keyboardShortcuts.value && e.key !== 'Enter' && e.key !== 'Escape') return - const values = navigableValues.value const count = values.length @@ -138,15 +135,27 @@ watch(highlightedIndex, index => { } }) -const containerRef = useTemplateRef('containerRef') -onClickOutside(containerRef, () => { +const listboxId = useId() +const optionId = (index: number) => `${listboxId}-option-${index}` +const isOpen = computed( + () => isInputFocused.value && (navigableValues.value.length > 0 || isSearching.value), +) +const hasOptions = computed(() => isOpen.value && navigableValues.value.length > 0) + +function closeList() { isInputFocused.value = false highlightedIndex.value = -1 -}) +} +const containerRef = useTemplateRef('containerRef') +onClickOutside(containerRef, closeList) +// Tabbing away must close it too, or the open list covers whatever gets focus next +function onFocusout(e: FocusEvent) { + if (!containerRef.value?.contains(e.relatedTarget as Node | null)) closeList() +}