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), + ) +} + +// 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() +} + + + 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