Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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