+
+
+
diff --git a/app/components/Package/List.vue b/app/components/Package/List.vue
index 92fa300c0..c7bc5f885 100644
--- a/app/components/Package/List.vue
+++ b/app/components/Package/List.vue
@@ -179,7 +179,8 @@ defineExpose({
('pageSize', { required: true })
const emit = defineEmits<{
'toggleColumn': [columnId: ColumnId]
+ 'toggleSelection': []
'resetColumns': []
'clearFilter': [chip: FilterChip]
'clearAllFilters': []
@@ -110,6 +111,8 @@ const sortKeyLabelKeys = computed>(() => ({
function getSortKeyLabelKey(key: SortKey): string {
return sortKeyLabelKeys.value[key]
}
+
+const { selectedPackages, clearSelectedPackages } = usePackageSelection()
@@ -211,6 +214,26 @@ function getSortKeyLabelKey(key: SortKey): string {
+
+
+
+ {{ t('filters.view_selected') }} ({{ selectedPackages.length }})
+
+
+
diff --git a/app/components/Package/SelectionCheckbox.vue b/app/components/Package/SelectionCheckbox.vue
new file mode 100644
index 000000000..dddebc467
--- /dev/null
+++ b/app/components/Package/SelectionCheckbox.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
diff --git a/app/components/Package/SelectionView.vue b/app/components/Package/SelectionView.vue
new file mode 100644
index 000000000..76e9350a1
--- /dev/null
+++ b/app/components/Package/SelectionView.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+ {{ $t('action_bar.selection', selectedPackages.length) }}
+
+
+
+
+
+
+
+
+ {{ $t('filters.table.no_packages') }}
+
+
+
+
diff --git a/app/components/Package/Table.vue b/app/components/Package/Table.vue
index 63ec2f70f..dad39be4f 100644
--- a/app/components/Package/Table.vue
+++ b/app/components/Package/Table.vue
@@ -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 {
@@ -299,6 +300,9 @@ function getColumnLabel(id: ColumnId): string {
>
{{ getColumnLabel('security') }}
+
+ {{ getColumnLabel('selection') }}
+ |
@@ -326,6 +330,9 @@ function getColumnLabel(id: ColumnId): string {
|
+
+
+ |
diff --git a/app/components/Package/TableRow.vue b/app/components/Package/TableRow.vue
index cb9f5f300..b0070d793 100644
--- a/app/components/Package/TableRow.vue
+++ b/app/components/Package/TableRow.vue
@@ -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, isMaxSelected } = usePackageSelection()
+const isSelected = computed(() => {
+ return isPackageSelected(props.result.package.name)
+})
function formatDownloads(count?: number): string {
if (count === undefined) return '-'
@@ -196,6 +200,15 @@ const allMaintainersText = computed(() => {
-
+
+
+
+ |
diff --git a/app/composables/usePackageSelection.ts b/app/composables/usePackageSelection.ts
new file mode 100644
index 000000000..a88a13398
--- /dev/null
+++ b/app/composables/usePackageSelection.ts
@@ -0,0 +1,77 @@
+export const MAX_PACKAGE_SELECTION = 4
+
+export function usePackageSelection() {
+ const selectedPackagesParam = useRouteQuery('selection', '', { mode: 'push' })
+ const showSelectionViewParam = useRouteQuery('view', '', { mode: 'push' })
+
+ // Parse URL param into array of package names
+ const selectedPackages = computed({
+ get() {
+ const raw = selectedPackagesParam.value
+ if (!raw) return []
+ return raw
+ .split(',')
+ .map(p => String(p).trim())
+ .filter(Boolean)
+ .slice(0, MAX_PACKAGE_SELECTION)
+ },
+ set(pkgs: string[]) {
+ // Ensure all items are strings before joining
+ const validPkgs = (Array.isArray(pkgs) ? pkgs : []).map(p => String(p).trim()).filter(Boolean)
+ selectedPackagesParam.value = validPkgs.length > 0 ? validPkgs.join(',') : ''
+ },
+ })
+
+ const isMaxSelected = computed(() => selectedPackages.value.length >= MAX_PACKAGE_SELECTION)
+
+ const showSelectionView = computed({
+ get() {
+ return showSelectionViewParam.value === 'selection'
+ },
+ set(isOpen: boolean) {
+ showSelectionViewParam.value = isOpen ? 'selection' : ''
+ },
+ })
+
+ function closeSelectionView() {
+ showSelectionView.value = false
+ }
+
+ function isPackageSelected(packageName: string): boolean {
+ return selectedPackages.value.includes(String(packageName).trim())
+ }
+
+ function togglePackageSelection(packageName: string) {
+ const safeName = String(packageName).trim()
+ if (!safeName) return
+
+ const pkgs = [...selectedPackages.value]
+ const idx = pkgs.indexOf(safeName)
+ if (idx !== -1) {
+ pkgs.splice(idx, 1)
+ } else {
+ if (pkgs.length < MAX_PACKAGE_SELECTION) pkgs.push(safeName)
+ }
+ selectedPackages.value = pkgs
+ }
+
+ function clearSelectedPackages() {
+ selectedPackages.value = []
+ }
+
+ function openSelectionView() {
+ showSelectionView.value = true
+ }
+
+ return {
+ selectedPackages,
+ selectedPackagesParam,
+ showSelectionView,
+ isMaxSelected,
+ clearSelectedPackages,
+ isPackageSelected,
+ togglePackageSelection,
+ closeSelectionView,
+ openSelectionView,
+ }
+}
diff --git a/app/pages/search.vue b/app/pages/search.vue
index cd3faef3e..db20ea913 100644
--- a/app/pages/search.vue
+++ b/app/pages/search.vue
@@ -9,6 +9,15 @@ import { normalizeSearchParam } from '#shared/utils/url'
const route = useRoute()
+const { selectedPackages, showSelectionView, openSelectionView, closeSelectionView } =
+ usePackageSelection()
+
+watch(selectedPackages, packages => {
+ if (packages.length === 0) {
+ closeSelectionView()
+ }
+})
+
// Preferences (persisted to localStorage)
const {
viewMode,
@@ -558,16 +567,33 @@ defineOgImageComponent('Default', {
+
+
{{ $t('search.title') }}
-
+
+
-
+
+
+
{
expect(results.violations).toEqual([])
})
})
+
+ describe('PackageActionBar', () => {
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(PackageActionBar)
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
+
+ describe('PackageSelectionView', () => {
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(PackageSelectionView)
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('should have no accessibility violations changing view mode', async () => {
+ const component = await mountSuspended(PackageSelectionView, {
+ props: {
+ viewMode: 'table',
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
+
+ describe('PackageSelectionCheckbox', () => {
+ it('should have no accessibility violations', async () => {
+ const component = await mountSuspended(PackageSelectionCheckbox, {
+ props: {
+ packageName: 'nuxt',
+ checked: false,
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('should have no accessibility violations when disabled', async () => {
+ const component = await mountSuspended(PackageSelectionCheckbox, {
+ props: {
+ packageName: 'nuxt',
+ checked: false,
+ disabled: true,
+ },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
})
function applyTheme(colorMode: string, bgTheme: string | null) {
diff --git a/test/unit/shared/types/index.spec.ts b/test/unit/shared/types/index.spec.ts
index 4cd8447c8..0ad0c4de3 100644
--- a/test/unit/shared/types/index.spec.ts
+++ b/test/unit/shared/types/index.spec.ts
@@ -64,6 +64,6 @@ describe('npm registry types', () => {
expect(response.total).toBe(1)
expect(response.objects[0]?.package.name).toBe('test-package')
- expect(response.objects[0]?.score.final).toBe(0.9)
+ expect(response.objects[0]?.score?.final).toBe(0.9)
})
})