Skip to content

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

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

feat(ui): add multi-select and bulk actions for packages#1672
MatteoGabriele wants to merge 58 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 15, 2026 6:36pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 15, 2026 6:36pm
npmx-lunaria Ignored Ignored Mar 15, 2026 6:36pm

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
i18n/locales/en.json Source changed, localizations will be marked as outdated.
i18n/locales/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 61.81818% with 42 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 47.36% 7 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 75.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Member

@knowler knowler left a comment

Choose a reason for hiding this comment

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

Looks good to me from an accessibility perspective. We can always iterate/tweak things later. Others should review the Vue/JS before we merge.

Copy link
Member

@serhalp serhalp left a comment

Choose a reason for hiding this comment

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

This is an awesome feature! It looks and feels great. Just found a few issues.

Comment on lines +15 to +18
// Don't scroll for other param changes (filters, pagination, etc.)
if (to.path === from.path) {
return false
}
Copy link
Member

Choose a reason for hiding this comment

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

I believe this breaks the hash anchor navigation logic just below this, by returning too early before we can check it.

My hunch is always to add a test for the behaviour that almost broke when this occurs, as it's a sign we're missing valuable coverage!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Navigation with hashes works. I can add tests for the scroll behavior: should I go with e2e or unit tests?

const selectedPackagesParam = useRouteQuery<string>('selection', '', { mode: 'push' })
const showSelectionViewParam = useRouteQuery<string>('view', '', { mode: 'push' })

// Parse URL param into array of package names
Copy link
Member

Choose a reason for hiding this comment

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

🤔 I'm all for the URL-as-source-of-truth-for-state school of thought, but I wonder if selection within search results falls into the ephemeral state bucket that should not be in the URL and consequently in the browser navigation history?

When I play around with it, it admittedly looks cool to hit back and forward across those selections/deselections, but it feels surprising.

I'd be curious to hear other opinions, but my hunch would be not to include it in the URL.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I need to include it in the URL; otherwise, I won't be able to refresh and get the list from your selection. This is especially helpful if you refresh, navigate to other pages, and then come back. It also makes the selection page shareable. I agree that perhaps it shouldn't be pushed into history, but it can be replaced. Let me know what you think.
I have already made this change.

Copy link
Member

Choose a reason for hiding this comment

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

Ah yeah, the selection page feels different to me than the existing search results page with selections.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is that a good thing or a bad thing?

Copy link
Member

Choose a reason for hiding this comment

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

Sorry, that wasn't very clear. I meant that on the new selection view I agree selections should be part of the URL and navigation history, but on the normal full search results view selection state feels more ephemeral. 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried using useState in a previous version, but the saved state felt wrong, and the data would appear when you didn't expect it to. I believe this one is best suited to a URL: I don't think this functionality fits any other storage options that don't require complex babysitting. URL, in a way, is straightforward, managed by navigation, and predictable. You go back and find what you left; you arrive there from another route and see a clean slate. That said, if you have another idea, I'm happy to try it out and see how it feels.

},
})

const isMaxSelected = computed(() => selectedPackages.value.length >= MAX_PACKAGE_SELECTION)
Copy link
Member

Choose a reason for hiding this comment

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

nit: this is super nitpicky but (especially since it's exported) this could be more expressive of the intent rather than the implementation, something like:

Suggested change
const isMaxSelected = computed(() => selectedPackages.value.length >= MAX_PACKAGE_SELECTION)
const shouldDisableSelection = computed(() => selectedPackages.value.length >= MAX_PACKAGE_SELECTION)

Copy link
Contributor Author

@MatteoGabriele MatteoGabriele Mar 15, 2026

Choose a reason for hiding this comment

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

I can change it, but I disagree with this one. This is a composable property that tells the component that the maximum selectable items have been reached (perhaps the name could be tweaked), but it is not the job of the composable to tell you that it is disabled, since other parts of the UI might be bound to a completely different functionality or state. Ex: one component disables a checkbox, another component simply displays a tooltip.

Copy link
Member

Choose a reason for hiding this comment

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

I can see that. Maybe canSelectMore?

Comment on lines +40 to +41
function isPackageSelected(packageName: string): boolean {
return selectedPackages.value.includes(String(packageName).trim())
Copy link
Member

Choose a reason for hiding this comment

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

nit: the type says packageName is a string but we're casting it to a string. Is the type wrong or the cast unnecessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch! Those are leftovers from when I was trying to hack some stuff around 👍

Comment on lines +44 to +45
function togglePackageSelection(packageName: string) {
const safeName = String(packageName).trim()
Copy link
Member

Choose a reason for hiding this comment

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

same nit here

selectedPackages.value.map(name =>
$fetch(`/api/registry/package-meta/${encodeURIComponent(name)}`)
.then(response => ({ package: response }))
.catch(() => []),
Copy link
Member

@serhalp serhalp Mar 14, 2026

Choose a reason for hiding this comment

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

This fallback's type is incorrect.

Suggested change
.catch(() => []),
.catch(() => ({})),

This may be another good case for test coverage!

btw we can type this a little closer to the fetch response by passing a generic: https://npmx.dev/package/ofetch#user-content-type-friendly

<button
@click="clearSelectedPackages"
class="flex items-center ms-1 text-fg-muted hover:(text-fg bg-accent/10) p-1.5 rounded-lg transition-colors"
aria-label="Close action bar"
Copy link
Member

Choose a reason for hiding this comment

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

⚠️ this string needs to be internationalized

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

3 participants