Skip to content

feat(ui): add multi-select and bulk actions for packages#1672

Open
MatteoGabriele wants to merge 36 commits intonpmx-dev:mainfrom
MatteoGabriele:feat/action-bar
Open

feat(ui): add multi-select and bulk actions for packages#1672
MatteoGabriele wants to merge 36 commits intonpmx-dev:mainfrom
MatteoGabriele:feat/action-bar

Conversation

@MatteoGabriele
Copy link
Contributor

@MatteoGabriele MatteoGabriele commented Feb 26, 2026

🔗 Linked issue

resolves #1509

🧭 Context

Added a multi-select feature to the search page that allows users to select multiple packages and perform bulk actions on them. Currently supports comparing selected packages, with the possibility of adding more actions in the future.

📚 Description

  • Selection UI: Added checkboxes to package cards that appear on hover. Selected cards are visually indicated with a border highlight and checkbox state.
  • Persistent counter: New "View selected (X)" button in the toolbar shows active selections and navigates to a dedicated view for managing them.
  • Floating action bar: When items are selected, a floating action bar appears at the bottom with the selection count, primary action (Compare), and clear button.
  • Selection state management: Uses composable to maintain selections across view changes (card/table/selections view), allowing users to continue browsing while keeping their selections.
  • Selection view: It's a separate component view that retrieves each saved component similar to the Compare page request logic.
  • Accessibility: Includes aria-live announcements for selection changes and keyboard shortcuts ("b" key) to focus the action bar.

At the moment, the logic is locked at a maximum of 4 selectable items. This won't scale, but for now, to reduce complexity, it will mimic what's needed by the Compare page, which is the only current functionality available in the multi-select.
My take is to re-think this along the way, when and if another action gets added.

Screen.Recording.2026-02-27.at.18.33.58.mov

@vercel
Copy link

vercel bot commented Feb 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 1, 2026 6:07pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 1, 2026 6:07pm
npmx-lunaria Ignored Ignored Mar 1, 2026 6:07pm

Request Review

@github-actions
Copy link

github-actions bot commented Feb 26, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
lunaria/files/it-IT.json Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@codecov
Copy link

codecov bot commented Feb 27, 2026

Codecov Report

❌ Patch coverage is 62.28070% with 43 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/composables/usePackageSelection.ts 34.21% 19 Missing and 6 partials ⚠️
app/components/Package/ActionBar.vue 45.00% 8 Missing and 3 partials ⚠️
app/components/Package/ListToolbar.vue 50.00% 3 Missing and 1 partial ⚠️
app/router.options.ts 66.66% 2 Missing ⚠️
app/components/Package/TableRow.vue 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@MatteoGabriele MatteoGabriele changed the title feat(ui): add action bar feat(ui): add multi-select and bulk actions for packages Feb 28, 2026
@MatteoGabriele
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 1, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
app/composables/usePackageSelection.ts (1)

10-22: Canonicalise selection values before persisting to query state.

selectedPackages reads a capped list, but Line 20-Line 21 can still write duplicates or over-limit values into the URL. Keeping read/write logic symmetrical avoids query-state drift.

♻️ Suggested normalisation patch
 const selectedPackages = computed<string[]>({
   get() {
     const raw = selectedPackagesParam.value
     if (!raw) return []
-    return raw
-      .split(',')
-      .map(p => String(p).trim())
-      .filter(Boolean)
-      .slice(0, MAX_PACKAGE_SELECTION)
+    return [...new Set(
+      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)
+    const validPkgs = [...new Set(
+      (Array.isArray(pkgs) ? pkgs : [])
+        .map(p => String(p).trim())
+        .filter(Boolean),
+    )].slice(0, MAX_PACKAGE_SELECTION)
     selectedPackagesParam.value = validPkgs.length > 0 ? validPkgs.join(',') : ''
   },
 })

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3856538 and 70f7615.

📒 Files selected for processing (17)
  • app/components/Package/ActionBar.vue
  • app/components/Package/Card.vue
  • app/components/Package/ListToolbar.vue
  • app/components/Package/SelectionView.vue
  • app/components/Package/Table.vue
  • app/components/Package/TableRow.vue
  • app/composables/usePackageSelection.ts
  • app/pages/search.vue
  • app/router.options.ts
  • i18n/locales/en.json
  • i18n/locales/it-IT.json
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
  • lunaria/files/it-IT.json
  • shared/types/npm-registry.ts
  • test/unit/shared/types/index.spec.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • lunaria/files/en-US.json
  • i18n/locales/en.json
  • app/components/Package/ActionBar.vue
  • app/components/Package/SelectionView.vue
  • app/components/Package/ListToolbar.vue
  • app/components/Package/Table.vue
  • i18n/schema.json

Comment on lines 157 to 160
<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"
>
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

Prevent duplicate weekly-downloads rendering on small screens.

Line 157 renders this downloads row on all breakpoints, while Lines 121-133 already render a mobile-only downloads row. On small screens, users will see the same metric twice.

Proposed fix
-        <div
-          v-if="result.downloads?.weekly"
-          class="text-fg-subtle gap-2 flex items-center sm:justify-end"
-        >
+        <div
+          v-if="result.downloads?.weekly"
+          class="hidden sm:flex text-fg-subtle gap-2 items-center sm:justify-end"
+        >
📝 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
<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"
>
<div
v-if="result.downloads?.weekly"
class="hidden sm:flex text-fg-subtle gap-2 items-center sm:justify-end"
>

Comment on lines 216 to 219
:title="
isMaxSelected && !isSelected
? $t('package.card.select_maximum', MAX_PACKAGE_SELECTION)
: undefined
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

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the TableRow.vue file around the specified lines
cat -n app/components/Package/TableRow.vue | sed -n '210,225p'

Repository: npmx-dev/npmx.dev

Length of output: 902


🏁 Script executed:

# Find translation files to check the message definition
find . -type f \( -name "*.json" -o -name "*.yaml" -o -name "*.yml" \) | grep -i "i18n\|translation\|locale" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 572


🏁 Script executed:

# Search for the specific translation key
rg "select_maximum" --type json --type yaml

Repository: npmx-dev/npmx.dev

Length of output: 381


🏁 Script executed:

# Check other usages of $t with MAX_PACKAGE_SELECTION or similar pattern
rg "\$t\(" app/components/Package/TableRow.vue -A 2 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check the package.json to see vue-i18n version
cat package.json | grep -A 2 -B 2 "vue-i18n"

Repository: npmx-dev/npmx.dev

Length of output: 241


🏁 Script executed:

# Get the full package.json to find vue-i18n version
cat package.json | grep "vue-i18n" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 93


🏁 Script executed:

# Check the i18n schema to understand the expected format
cat i18n/schema.json | grep -A 10 "select_maximum"

Repository: npmx-dev/npmx.dev

Length of output: 331


🏁 Script executed:

# Check other similar $t() calls in the codebase to see the pattern used
rg "\$t\(" app/components/ -B 1 -A 1 | grep -E "\$t\(|count|MAX" | head -30

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for vue-i18n in dependencies more carefully
grep -E '"vue-i18n"' package.json -A 1 -B 1

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Look for examples of $t calls with parameters in the entire codebase
rg "\$t\(" --type vue -A 1 | grep -E "\$t\(|count|MAX_|{" | head -40

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check the i18n configuration/setup
find . -name "i18n*" -type f | grep -E "\.(ts|js|config)" | head -10

Repository: npmx-dev/npmx.dev

Length of output: 165


🏁 Script executed:

# Look for plural or named parameters in translation calls
rg "\$t\(" -A 1 | grep -B 1 "{" | head -30

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check full package.json for vue-i18n dependency
cat package.json | jq '.dependencies, .devDependencies' | grep -A 1 -B 1 "vue-i18n"

Repository: npmx-dev/npmx.dev

Length of output: 150


🏁 Script executed:

# Look at i18n configuration
cat i18n/i18n.config.ts

Repository: npmx-dev/npmx.dev

Length of output: 400


🏁 Script executed:

# Check for other $t calls with parameters
rg '\$t\(' app/ -A 1 -B 1 --max-count 50

Repository: npmx-dev/npmx.dev

Length of output: 50374


🏁 Script executed:

# Look at the i18n-loader plugin
cat app/plugins/i18n-loader.client.ts

Repository: npmx-dev/npmx.dev

Length of output: 629


Pass named interpolation params to select_maximum.

Line 218 currently passes a number directly to $t; the translation message uses the {count} placeholder, which requires a named params object. This is consistent with every similar usage throughout the codebase (e.g. $t('org.public_packages', { count: $n(results.total) }, results.total)).

✅ Suggested fix
-            :title="
-              isMaxSelected && !isSelected
-                ? $t('package.card.select_maximum', MAX_PACKAGE_SELECTION)
-                : undefined
-            "
+            :title="
+              isMaxSelected && !isSelected
+                ? $t('package.card.select_maximum', { count: MAX_PACKAGE_SELECTION })
+                : undefined
+            "

Comment on lines 10 to 13
// If only query parameters changed (same path), don't scroll
if (to.path === from.path) {
return false
}
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

🧩 Analysis chain

🌐 Web query:

Vue Router 4 scrollBehavior path hash query difference to.path to.fullPath

💡 Result:

In Vue Router 4, scrollBehavior(to, from, savedPosition) receives normalized route locations (to/from) that contain (among others) path, fullPath, query, and hash. (router.vuejs.org)

to.path vs to.fullPath

  • to.path: URL pathname only (no ?query, no #hash). Use this when you only care whether you changed “page/route” (same component/record, different query, etc.).
  • to.fullPath: the whole URL for the SPA location, including query string and hash (“search and hash”), and it’s percent-encoded as a string. Use this when you care about exact URL changes (query/hash changes included). (router.vuejs.org)

Practical implication:

  • Changing ?query=... usually keeps to.path the same, but changes to.fullPath.
  • Changing #hash keeps to.path the same, but changes to.fullPath.

to.query vs to.hash

  • to.query: the parsed query object (what comes after ?).
  • to.hash: the fragment (what comes after #), and in Router 4 it’s designed to be usable directly (e.g., for anchor scrolling). (router.vuejs.org)

How this matters in scrollBehavior

Common patterns:

1) Scroll to anchor when there’s a hash

scrollBehavior(to, from, savedPosition) {
  if (savedPosition) return savedPosition
  if (to.hash) return { el: to.hash }
  return { top: 0 }
}

(Using el: to.hash is the Router 4 anchor-scrolling style.) (vue-router.netlify.app)

2) Don’t scroll when only query/hash changed within the same path

scrollBehavior(to, from, savedPosition) {
  if (savedPosition) return savedPosition
  if (to.hash) return { el: to.hash }

  // same “page”, only query changed
  if (to.path === from.path) return {}

  return { top: 0 }
}

This works because path ignores query/hash, while fullPath changes when they change. (router.vuejs.org)

Citations:


🏁 Script executed:

# First, let's find and examine the actual file
fd app/router.options.ts

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Read the actual file to see the full context
cat -n app/router.options.ts

Repository: npmx-dev/npmx.dev

Length of output: 1001


Hash-anchor navigation is being blocked on same-path routes.

At Line 11, to.path === from.path matches hash-only navigations (for example /search#foo/search#bar). Because this returns false before the hash handling at Line 16, anchor scrolling is skipped.

Proposed fix
-    // If only query parameters changed (same path), don't scroll
-    if (to.path === from.path) {
+    // If only query parameters changed (same path + same hash), don't scroll
+    if (to.path === from.path && to.hash === from.hash && to.fullPath !== from.fullPath) {
       return false
     }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ux Related to wider UX decisions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Select packages to compare from the search results view

2 participants