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()
-
-
-
-
- /
+
+
+
+
+
+ {{ $t('compare.no_dependency.typeahead_title') }}
-
-
-
-
-
-
-
-
-
-
- {{ $t('compare.no_dependency.typeahead_title') }}
-
-
- {{ $t('compare.no_dependency.typeahead_description') }}
-
-
-
-
- {{ $t('compare.selector.searching') }}
-
-
- {{ result.name }}
-
- {{ stripHtmlTags(decodeHtmlEntities(result.description)) }}
-
-
-
-
-
+
+ {{ $t('compare.no_dependency.typeahead_description') }}
+
+
+
+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),
+ )
+}
+
+// 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) {
+ 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 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()
+}
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+ {{ result.name }}
+
+ {{ stripHtmlTags(decodeHtmlEntities(result.description)) }}
+
+
+
+
+ {{ $t('search.searching') }}
+
+
+
+
+
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/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts
index 2ac88c05fc..aaabd3c77e 100644
--- a/test/e2e/interactions.spec.ts
+++ b/test/e2e/interactions.spec.ts
@@ -30,7 +30,7 @@ test.describe('Compare Page', () => {
await input.fill('nuxt')
// Wait for search results and click on nuxt
- const nuxtOption = page.locator('button:has-text("nuxt")').first()
+ const nuxtOption = page.getByRole('option').filter({ hasText: 'nuxt' }).first()
await expect(nuxtOption).toBeVisible({ timeout: 10000 })
await nuxtOption.click()
diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts
index f17d325cd4..f38cf42cc5 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,32 @@ 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([])
+ })
+
+ it('should have no accessibility violations with the list open', async () => {
+ const component = await mountSuspended(PackageTypeahead, {
+ props: {
+ inputId: 'typeahead-a11y-open',
+ label: 'Search for packages',
+ leadingOptions: [{ value: 'special', label: 'Special' }],
+ },
+ })
+ const input = component.find('input')
+ await input.trigger('focus')
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ expect(input.attributes('aria-activedescendant')).toBeTruthy()
+ 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..630bfc6ad4
--- /dev/null
+++ b/test/nuxt/components/Package/Typeahead.spec.ts
@@ -0,0 +1,73 @@
+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('navigates options even with keyboard shortcuts disabled', async () => {
+ const { settings } = useSettings()
+ settings.value.keyboardShortcuts = false
+ const component = await mount()
+ const input = component.find('input')
+ await input.setValue('schema')
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ await input.trigger('keydown', { key: 'Enter' })
+ settings.value.keyboardShortcuts = true
+
+ expect(component.emitted('select')).toEqual([['zod']])
+ })
+
+ 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