Skip to content
Open
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/components/BaseCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
defineProps<{
/** Whether this is an exact match for the query */
isExactMatch?: boolean
selected?: boolean
}>()
</script>

Expand All @@ -10,6 +11,7 @@ defineProps<{
class="group bg-bg-subtle border border-border rounded-lg p-4 sm:p-6 transition-[border-color,background-color] duration-200 hover:(border-border-hover bg-bg-muted) cursor-pointer relative focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 focus-within:bg-bg-muted focus-within:border-border-hover"
:class="{
'border-accent/30 contrast-more:border-accent/90 bg-accent/5': isExactMatch,
'bg-fg-subtle/15!': selected,
}"
>
<!-- Glow effect for exact matches -->
Expand Down
1 change: 1 addition & 0 deletions app/components/ColumnPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const columnLabels = computed(() => ({
maintenanceScore: $t('filters.columns.maintenance_score'),
combinedScore: $t('filters.columns.combined_score'),
security: $t('filters.columns.security'),
selection: $t('filters.columns.selection'),
}))

function getColumnLabel(id: ColumnId): string {
Expand Down
79 changes: 79 additions & 0 deletions app/components/Package/ActionBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script setup lang="ts">
const { selectedPackages, selectedPackagesParam, clearSelectedPackages } = usePackageSelection()

const shortcutKey = 'b'
const actionBar = useTemplateRef('actionBarRef')
onKeyStroke(
e => {
const target = e.target as HTMLElement
const isCheckbox = target.hasAttribute('data-package-card-checkbox')
return isKeyWithoutModifiers(e, shortcutKey) && (!isEditableElement(target) || isCheckbox)
},
e => {
if (selectedPackages.value.length === 0) {
return
}

e.preventDefault()
actionBar.value?.focus()
},
)
</script>

<template>
<Transition name="action-bar-slide" appear>
<div
v-if="selectedPackages.length"
class="fixed bottom-10 inset-is-0 w-full flex items-center justify-center z-36 pointer-events-none"
>
<div
ref="actionBarRef"
tabindex="-1"
aria-keyshortcuts="b"
class="pointer-events-auto bg-bg shadow-xl shadow-accent/5 border border-fg-muted/20 p-2.5 min-w-[300px] rounded-xl flex gap-2 items-center justify-between animate-in"
>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ $t('action_bar.selection', selectedPackages.length) }}.
{{ $t('action_bar.shortcut', { key: shortcutKey }) }}.
</div>

<div class="flex items-center gap-1 ms-2">
<span class="text-fg text-sm">
{{ $t('action_bar.selection', selectedPackages.length) }}
</span>
<button @click="clearSelectedPackages" class="flex items-center ms-2 hover:text-fg-muted">
<span class="i-lucide:x text-xs relative top-px" aria-label="Close action bar" />
</button>
</div>

<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
Compare
</LinkBase>
Comment on lines +44 to +55
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The action-bar controls need accessible labelling and localisation cleanup.

Line 40-42 uses an icon-only close button without a button-level label, and Line 50 hardcodes visible text (Compare) instead of using i18n.

✅ Suggested patch
-          <button `@click`="clearSelectedPackages" class="flex items-center ms-2 hover:text-fg-muted">
-            <span class="i-lucide:x text-xs relative top-px" aria-label="Close action bar" />
+          <button
+            type="button"
+            :aria-label="$t('common.close')"
+            `@click`="clearSelectedPackages"
+            class="flex items-center ms-2 hover:text-fg-muted"
+          >
+            <span class="i-lucide:x text-xs relative top-px" aria-hidden="true" />
           </button>
@@
-          Compare
+          {{ $t('package.links.compare') }}
         </LinkBase>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button @click="clearSelectedPackages" class="flex items-center ms-2 hover:text-fg-muted">
<span class="i-lucide:x text-xs relative top-px" aria-label="Close action bar" />
</button>
</div>
<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
Compare
</LinkBase>
<button
type="button"
:aria-label="$t('common.close')"
`@click`="clearSelectedPackages"
class="flex items-center ms-2 hover:text-fg-muted"
>
<span class="i-lucide:x text-xs relative top-px" aria-hidden="true" />
</button>
</div>
<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-secondary"
classicon="i-lucide:git-compare"
>
{{ $t('package.links.compare') }}
</LinkBase>

</div>
</div>
</Transition>
</template>

<style scoped>
/* Action bar slide/fade animation */
.action-bar-slide-enter-active,
.action-bar-slide-leave-active {
transition:
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-bar-slide-enter-from,
.action-bar-slide-leave-to {
opacity: 0;
transform: translateY(40px) scale(0.98);
}
.action-bar-slide-enter-to,
.action-bar-slide-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
</style>
59 changes: 30 additions & 29 deletions app/components/Package/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const props = defineProps<{
searchQuery?: string
}>()

const { isPackageSelected, togglePackageSelection } = usePackageSelection()
const isSelected = computed<boolean>(() => {
return isPackageSelected(props.result)
})

const emit = defineEmits<{
clickKeyword: [keyword: string]
}>()
Expand All @@ -39,16 +44,16 @@ const numberFormatter = useNumberFormatter()
</script>

<template>
<BaseCard :isExactMatch="isExactMatch">
<div class="mb-2 flex items-baseline justify-start gap-2">
<BaseCard :selected="isSelected" :isExactMatch="isExactMatch">
<header class="mb-4 flex items-baseline justify-between gap-2">
<component
:is="headingLevel ?? 'h3'"
class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all"
>
<NuxtLink
:to="packageRoute(result.package.name)"
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0"
class="decoration-none after:content-[''] after:absolute after:inset-0"
:data-result-index="index"
dir="ltr"
>{{ result.package.name }}</NuxtLink
Expand All @@ -59,28 +64,25 @@ const numberFormatter = useNumberFormatter()
>{{ $t('search.exact_match') }}</span
>
</component>
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
<!-- Mobile: version next to package name -->
<div class="sm:hidden text-fg-subtle flex items-center gap-1.5 shrink-0">
<span
v-if="result.package.version"
class="font-mono text-xs truncate max-w-20"
:title="result.package.version"
>
v{{ result.package.version }}
</span>
<ProvenanceBadge
v-if="result.package.publisher?.trustedPublisher"
:provider="result.package.publisher.trustedPublisher.id"
:package-name="result.package.name"
:version="result.package.version"
:linked="false"
compact
/>

<div class="relative z-1">
<label>
<span class="sr-only">
{{ $t('package.card.select') }}
</span>
Comment on lines +70 to +72
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Make checkbox accessible names package-specific.

Each checkbox currently announces the same label (“Select package”), which is ambiguous when navigating multiple results with assistive tech. Include the package name in the accessible label.

💡 Suggested tweak
-          <span class="sr-only">
-            {{ $t('package.card.select') }}
-          </span>
+          <span class="sr-only">
+            {{ `${$t('package.card.select')}: ${result.package.name}` }}
+          </span>

<input
data-package-card-checkbox
class="md:opacity-0 group-focus-within:opacity-100 checked:opacity-100 md:group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors"
type="checkbox"
:checked="isSelected"
@change="togglePackageSelection(result)"
/>
</label>
</div>
</div>
<div class="flex justify-start items-start gap-4 sm:gap-8">
<div class="min-w-0">
</header>

<div class="flex flex-col sm:flex-row sm:justify-start sm:items-start gap-6 sm:gap-8">
<div class="min-w-0 w-full">
<p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3">
<span v-html="pkgDescription" />
</p>
Expand Down Expand Up @@ -124,10 +126,9 @@ const numberFormatter = useNumberFormatter()
</div>
</dl>
</div>
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
<!-- Desktop: version and downloads on right side -->
<div class="hidden sm:flex flex-col gap-2 shrink-0">
<div class="text-fg-subtle flex items-start gap-2 justify-end">

<div class="flex flex-col gap-2 shrink-0">
<div class="text-fg-subtle flex items-start gap-2 sm:justify-end">
<span
v-if="result.package.version"
class="font-mono text-xs truncate max-w-32"
Expand All @@ -150,7 +151,7 @@ const numberFormatter = useNumberFormatter()
</div>
<div
v-if="result.downloads?.weekly"
class="text-fg-subtle gap-2 flex items-center justify-end"
class="text-fg-subtle gap-2 flex items-center sm:justify-end"
>
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
<span class="font-mono text-xs">
Expand Down
3 changes: 2 additions & 1 deletion app/components/Package/List.vue
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ defineExpose({
<template #default="{ item, index }">
<div class="pb-4">
<PackageCard
:result="item as NpmSearchResult"
:key="item.package.name"
:result="item"
:heading-level="headingLevel"
:show-publisher="showPublisher"
:index="index"
Expand Down
19 changes: 19 additions & 0 deletions app/components/Package/ListToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const pageSize = defineModel<PageSize>('pageSize', { required: true })

const emit = defineEmits<{
'toggleColumn': [columnId: ColumnId]
'toggleSelection': []
'resetColumns': []
'clearFilter': [chip: FilterChip]
'clearAllFilters': []
Expand Down Expand Up @@ -110,6 +111,8 @@ const sortKeyLabelKeys = computed<Record<SortKey, string>>(() => ({
function getSortKeyLabelKey(key: SortKey): string {
return sortKeyLabelKeys.value[key]
}

const { selectedPackages, clearSelectedPackages } = usePackageSelection()
</script>

<template>
Expand Down Expand Up @@ -211,6 +214,22 @@ function getSortKeyLabelKey(key: SortKey): string {

<ViewModeToggle v-model="viewMode" />
</div>

<div
class="flex items-center order-3 border-is border-fg-subtle/20 ps-3"
v-if="selectedPackages.length"
>
<ButtonBase
variant="secondary"
@click="emit('toggleSelection')"
classicon="i-lucide:package-check"
>
{{ t('filters.view_selected') }} ({{ selectedPackages.length }})
</ButtonBase>
<button @click="clearSelectedPackages" class="flex items-center ms-2">
<span class="i-lucide:x text-sm" aria-label="Close action bar" />
</button>
Comment on lines +229 to +231
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clear-selection icon button is missing a reliable accessible name.

Line 229 renders an icon-only button, but the label is on the child <span> (Line 230), not on the <button> itself.

✅ Suggested patch
-          <button `@click`="clearSelectedPackages" class="flex items-center ms-2">
-            <span class="i-lucide:x text-sm" aria-label="Close action bar" />
+          <button
+            type="button"
+            :aria-label="t('common.close')"
+            `@click`="clearSelectedPackages"
+            class="flex items-center ms-2"
+          >
+            <span class="i-lucide:x text-sm" aria-hidden="true" />
           </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button @click="clearSelectedPackages" class="flex items-center ms-2">
<span class="i-lucide:x text-sm" aria-label="Close action bar" />
</button>
<button
type="button"
:aria-label="t('common.close')"
`@click`="clearSelectedPackages"
class="flex items-center ms-2"
>
<span class="i-lucide:x text-sm" aria-hidden="true" />
</button>

</div>
</div>
</div>

Expand Down
41 changes: 41 additions & 0 deletions app/components/Package/SelectionView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
viewMode?: ViewMode
}>()
Comment on lines +2 to +4
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Verify ViewMode import status in the target file and check for global declarations
rg -n "defineProps<\{|viewMode\?: ViewMode|import.*ViewMode|export.*ViewMode" app/components/Package/SelectionView.vue

# Check for global ViewMode declarations
rg -n "type ViewMode|interface ViewMode|declare.*ViewMode" --type ts --type d.ts

Repository: npmx-dev/npmx.dev

Length of output: 146


🏁 Script executed:

# Read the full SelectionView.vue file to see all imports
cat -n app/components/Package/SelectionView.vue

# Search for ViewMode type definition globally with proper patterns
rg "type ViewMode|interface ViewMode" --type ts

# Check if the suggested import path exists
fd "preferences" --type f

Repository: npmx-dev/npmx.dev

Length of output: 1753


🏁 Script executed:

# Search for TypeScript/Nuxt configuration to verify the `#shared` alias
fd "tsconfig\|nuxt\.config\|vite\.config" --type f

# Check for any global type declarations
rg "declare global|declare module|ambient" --type ts

# Search for any .d.ts files at root or shared directories
find . -name "*.d.ts" -type f | head -20

Repository: npmx-dev/npmx.dev

Length of output: 514


🏁 Script executed:

# Find and inspect TypeScript/Nuxt config files
cat -n tsconfig.json 2>/dev/null || echo "tsconfig.json not found"
cat -n nuxt.config.ts 2>/dev/null || echo "nuxt.config.ts not found"

# Also check for alias resolution in vite config or other config
find . -maxdepth 2 -name "nuxt.config.*" -o -name "tsconfig.json" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 13729


🏁 Script executed:

# Check if Nuxt auto-generates `#shared` alias by looking for nuxt auto-import config
# or check if there's any auto-import configuration for types
rg "auto|#shared" nuxt.config.ts

# Verify if ViewMode is used elsewhere and how it's imported
rg "ViewMode" --type ts --type vue | head -30

Repository: npmx-dev/npmx.dev

Length of output: 145


🏁 Script executed:

# Check how ViewMode is imported in other files
rg "import.*ViewMode" --type ts

# Also check the app's tsconfig to see if it has the `#shared` alias
cat -n .nuxt/tsconfig.app.json 2>/dev/null | head -50

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check all files in .nuxt directory that might have path aliases
find .nuxt -name "tsconfig*.json" -type f | head -10

# Check if any tsconfig actually has the `#shared` path
rg "#shared" --type ts

# Let's look for any import of ViewMode in the entire codebase
rg "ViewMode" | grep -i import

Repository: npmx-dev/npmx.dev

Length of output: 18137


ViewMode type is unresolved—add missing import.

The defineProps and template usage reference ViewMode without an import. The type is exported from #shared/types/preferences and must be imported locally in this file.

 <script setup lang="ts">
+import type { ViewMode } from '#shared/types/preferences'
+
 defineProps<{
   viewMode?: ViewMode
 }>()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defineProps<{
viewMode?: ViewMode
}>()
<script setup lang="ts">
import type { ViewMode } from '#shared/types/preferences'
defineProps<{
viewMode?: ViewMode
}>()


const { selectedPackages, clearSelectedPackages, selectedPackagesParam } = usePackageSelection()
</script>

<template>
<section>
<header class="mb-6 flex items-center justify-end">
<div class="flex items-center gap-2">
<ButtonBase variant="secondary" @click="clearSelectedPackages" classicon="i-lucide:x">
{{ $t('filters.clear_all') }}
</ButtonBase>
<LinkBase
:to="{ name: 'compare', query: { packages: selectedPackagesParam } }"
variant="button-primary"
classicon="i-lucide:git-compare"
>
{{ $t('package.links.compare') }}
</LinkBase>
</div>
</header>

<p class="text-fg-muted text-sm font-mono">
{{ $t('action_bar.selection', selectedPackages.length) }}
</p>

<div class="mt-6">
<PackageList
v-if="selectedPackages.length > 0"
:view-mode="viewMode"
:results="selectedPackages"
search-context
heading-level="h2"
show-publisher
/>
</div>
</section>
</template>
4 changes: 4 additions & 0 deletions app/components/Package/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const columnLabels = computed(() => ({
maintenanceScore: t('filters.columns.maintenance_score'),
combinedScore: t('filters.columns.combined_score'),
security: t('filters.columns.security'),
selection: t('filters.columns.selection'),
}))

function getColumnLabel(id: ColumnId): string {
Expand Down Expand Up @@ -299,6 +300,9 @@ function getColumnLabel(id: ColumnId): string {
>
{{ getColumnLabel('security') }}
</th>
<th class="w-8">
<span class="sr-only">{{ getColumnLabel('selection') }}</span>
</th>
Comment on lines +303 to +305
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Selection header introduces a loading-state column mismatch.

After adding this <th>, skeleton rows still render fewer <td> cells, so the table structure is inconsistent while loading.

💡 Suggested patch
-          <th class="w-8">
+          <th scope="col" class="w-8">
             <span class="sr-only">{{ getColumnLabel('selection') }}</span>
           </th>
         <template v-if="isLoading && results.length === 0">
           <tr v-for="i in 5" :key="`skeleton-${i}`" class="border-b border-border">
@@
             <td v-if="isColumnVisible('keywords')" class="py-3 px-3">
               <div class="h-4 w-32 bg-bg-muted rounded animate-pulse ms-auto" />
             </td>
+            <td class="py-3 px-3 w-8">
+              <div class="h-4 w-4 bg-bg-muted rounded animate-pulse ms-auto" />
+            </td>
           </tr>
         </template>

</tr>
</thead>
<tbody>
Expand Down
21 changes: 21 additions & 0 deletions app/components/Package/TableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const pkg = computed(() => props.result.package)
const score = computed(() => props.result.score)

const updatedDate = computed(() => props.result.package.date)
const { isPackageSelected, togglePackageSelection } = usePackageSelection()
const isSelected = computed<boolean>(() => {
return isPackageSelected(props.result)
})

function formatDownloads(count?: number): string {
if (count === undefined) return '-'
Expand Down Expand Up @@ -196,6 +200,23 @@ const allMaintainersText = computed(() => {
</span>
<span v-else class="text-fg-subtle"> - </span>
</td>

<td class="pe-2 relative z-1">
<div class="flex items-center justify-center h-full">
<label>
<span class="sr-only">
{{ $t('package.card.select') }}
</span>
<input
data-package-card-checkbox
class="md:opacity-0 group-focus-within:opacity-100 checked:opacity-100 md:group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors"
type="checkbox"
:checked="isSelected"
@change="togglePackageSelection(result)"
/>
</label>
</div>
</td>
</tr>
</template>

Expand Down
32 changes: 32 additions & 0 deletions app/composables/usePackageSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export function usePackageSelection() {
const selectedPackages = useState<NpmSearchResult[]>('package_selection', () => [])
const selectedPackagesParam = computed<string>(() =>
selectedPackages.value.map(p => p.package.name).join(','),
)

function isPackageSelected(pkg: NpmSearchResult): boolean {
return selectedPackages.value.some(p => p.package.name === pkg.package.name)
}

function togglePackageSelection(pkg: NpmSearchResult) {
if (isPackageSelected(pkg)) {
selectedPackages.value = selectedPackages.value.filter(
selected => selected.package.name !== pkg.package.name,
)
} else {
selectedPackages.value = [...selectedPackages.value, pkg]
}
}

function clearSelectedPackages() {
selectedPackages.value = []
}

return {
selectedPackages,
selectedPackagesParam,
clearSelectedPackages,
isPackageSelected,
togglePackageSelection,
}
}
Loading
Loading