Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ if (import.meta.client) {
{{ route.name === 'search' ? `${$t('search.title_packages')} - npmx` : message }}
</NuxtRouteAnnouncer>

<NuxtAnnouncer />

<div id="main-content" class="flex-1 flex flex-col" tabindex="-1">
<NuxtPage />
</div>
Expand Down
148 changes: 72 additions & 76 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ async function loadMore() {
}
onBeforeUnmount(() => {
updateUrlPage.cancel()
announcePoliteDesktop.cancel()
announcePoliteMobile.cancel()
})

// Update URL when page changes from scrolling
Expand Down Expand Up @@ -584,94 +586,92 @@ defineOgImageComponent('Default', {
})

// -----------------------------------
// Live region debouncing logic
// Live region announcements
// -----------------------------------
const isMobile = useIsMobile()
const { polite } = useAnnouncer()

// Evaluate the text that should be announced to screen readers
const rawLiveRegionMessage = computed(() => {
if (isRateLimited.value) {
return $t('search.rate_limited')
}

// If status is pending, no update phrase needed yet
if (status.value === 'pending') {
return ''
}

if (visibleResults.value && displayResults.value.length > 0) {
if (viewMode.value === 'table' || paginationMode.value === 'paginated') {
const pSize = Math.min(preferredPageSize.value, effectiveTotal.value)

return $t(
'filters.count.showing_paginated',
{
pageSize: pSize.toString(),
count: $n(effectiveTotal.value),
},
effectiveTotal.value,
)
}

if (isRelevanceSort.value) {
return $t(
'search.found_packages',
{ count: $n(visibleResults.value.total) },
visibleResults.value.total,
)
}
const announcePoliteDesktop = debounce((message: string) => {
polite(message)
}, 250)

return $t(
'search.found_packages_sorted',
{ count: $n(effectiveTotal.value) },
effectiveTotal.value,
)
}
const announcePoliteMobile = debounce((message: string) => {
polite(message)
}, 700)

if (status.value === 'success' || status.value === 'error') {
if (displayResults.value.length === 0 && query.value) {
return $t('search.no_results', { query: query.value })
}
function announcePolite(message: string) {
if (isMobile.value) {
announcePoliteDesktop.cancel()
announcePoliteMobile(message)
return
}

return ''
})

const debouncedLiveRegionMessage = ref('')

const updateLiveRegionMobile = debounce((val: string) => {
debouncedLiveRegionMessage.value = val
}, 700)
announcePoliteMobile.cancel()
announcePoliteDesktop(message)
}

const updateLiveRegionDesktop = debounce((val: string) => {
debouncedLiveRegionMessage.value = val
}, 250)
function cancelPendingAnnouncements() {
announcePoliteDesktop.cancel()
announcePoliteMobile.cancel()
}

// Announce search results changes to screen readers
watch(
rawLiveRegionMessage,
newVal => {
if (!newVal) {
updateLiveRegionMobile.cancel()
updateLiveRegionDesktop.cancel()
debouncedLiveRegionMessage.value = ''
() => ({
rateLimited: isRateLimited.value,
searchStatus: status.value,
count: displayResults.value.length,
searchQuery: query.value,
mode: viewMode.value,
pagMode: paginationMode.value,
total: effectiveTotal.value,
}),
({ rateLimited, searchStatus, count, searchQuery, mode, pagMode, total }) => {
if (rateLimited) {
announcePolite($t('search.rate_limited'))
return
}

if (isMobile.value) {
updateLiveRegionDesktop.cancel()
updateLiveRegionMobile(newVal)
} else {
updateLiveRegionMobile.cancel()
updateLiveRegionDesktop(newVal)
// Don't announce while searching
if (searchStatus === 'pending') {
cancelPendingAnnouncements()
return
}

if (count > 0) {
if (mode === 'table' || pagMode === 'paginated') {
const pSize = Math.min(preferredPageSize.value, total)

announcePolite(
$t(
'filters.count.showing_paginated',
{
pageSize: pSize.toString(),
count: $n(total),
},
total,
),
)
} else if (isRelevanceSort.value) {
announcePolite(
$t(
'search.found_packages',
{ count: $n(visibleResults.value?.total ?? 0) },
visibleResults.value?.total ?? 0,
),
)
} else {
announcePolite($t('search.found_packages_sorted', { count: $n(total) }, total))
}
} else if (searchStatus === 'success' || searchStatus === 'error') {
if (searchQuery) {
announcePolite($t('search.no_results', { query: searchQuery }))
} else {
Comment on lines +620 to +669
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Watch the committed search state here, not the raw input.

Line 624 watches query.value, but the results come from committedQuery. When instant search is off, typing a new term can re-announce counts or “no results” for the previous result set. Lines 643 and 655 also read preferredPageSize.value and isRelevanceSort.value, but neither is in the source object, so page-size-only or sort-only changes can miss an announcement.

💡 Proposed fix
 watch(
   () => ({
     rateLimited: isRateLimited.value,
     searchStatus: status.value,
     count: displayResults.value.length,
-    searchQuery: query.value,
+    searchQuery: committedQuery.value,
     mode: viewMode.value,
     pagMode: paginationMode.value,
+    isRelevanceSort: isRelevanceSort.value,
+    pageSize: preferredPageSize.value,
     total: effectiveTotal.value,
   }),
-  ({ rateLimited, searchStatus, count, searchQuery, mode, pagMode, total }) => {
+  ({ rateLimited, searchStatus, count, searchQuery, mode, pagMode, isRelevanceSort, pageSize, total }) => {
     if (rateLimited) {
       announcePolite($t('search.rate_limited'))
       return
     }

     if (searchStatus === 'pending') {
       cancelPendingAnnouncements()
       return
     }

     if (count > 0) {
       if (mode === 'table' || pagMode === 'paginated') {
-        const pSize = Math.min(preferredPageSize.value, total)
+        const pSize = Math.min(pageSize, total)

         announcePolite(
           $t(
             'filters.count.showing_paginated',
             {
               pageSize: pSize.toString(),
               count: $n(total),
             },
             total,
           ),
         )
-      } else if (isRelevanceSort.value) {
+      } else if (isRelevanceSort) {
         announcePolite(
           $t(
             'search.found_packages',
             { count: $n(visibleResults.value?.total ?? 0) },
             visibleResults.value?.total ?? 0,

cancelPendingAnnouncements()
}
}
},
{ immediate: true },
)

onBeforeUnmount(() => {
updateLiveRegionMobile.cancel()
updateLiveRegionDesktop.cancel()
})
</script>

<template>
Expand Down Expand Up @@ -910,10 +910,6 @@ onBeforeUnmount(() => {
:package-scope="packageScope"
:can-publish-to-scope="canPublishToScope"
/>

<div role="status" class="sr-only">
{{ debouncedLiveRegionMessage }}
</div>
</main>
</template>

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"ipaddr.js": "2.3.0",
"marked": "17.0.4",
"module-replacements": "2.11.0",
"nuxt": "4.3.1",
"nuxt": "4.4.2",
"nuxt-og-image": "5.1.13",
"ofetch": "1.5.1",
"ohash": "2.0.11",
Expand Down
Loading
Loading