Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a880463
feat(ui): add action bar
MatteoGabriele Feb 26, 2026
3a12c63
Merge branch 'main' into feat/action-bar
MatteoGabriele Feb 26, 2026
9c8a88c
feat: add selection page ui
MatteoGabriele Feb 26, 2026
ed13532
feat: add packages in route query
MatteoGabriele Feb 26, 2026
812f685
chore: clean composable
MatteoGabriele Feb 27, 2026
3d244a0
Merge branch 'main' into feat/action-bar
MatteoGabriele Feb 27, 2026
287a4a7
feat: add back button and fix keys
MatteoGabriele Feb 27, 2026
8743c79
refactor: copy
MatteoGabriele Feb 27, 2026
7700125
feat: add table selection
MatteoGabriele Feb 27, 2026
1ee8c67
Merge branch 'main' into feat/action-bar
MatteoGabriele Feb 27, 2026
bab24a2
feat: add label
MatteoGabriele Feb 27, 2026
a96417e
Merge branch 'feat/action-bar' of https://github.com/MatteoGabriele/n…
MatteoGabriele Feb 27, 2026
481fc12
test: add a11y check
MatteoGabriele Feb 27, 2026
5e926ab
fix: types
MatteoGabriele Feb 27, 2026
d04f0d2
fix: only trigger when action bar is visible
MatteoGabriele Feb 27, 2026
deeb959
feat: add labels
MatteoGabriele Feb 28, 2026
ad5ea8d
refactor: better accessible name
MatteoGabriele Feb 28, 2026
40134a3
refactor: remove icon from button
MatteoGabriele Feb 28, 2026
b93dfa8
Merge branch 'main' into feat/action-bar
MatteoGabriele Feb 28, 2026
3856538
refactor: remove borders on mobile
MatteoGabriele Feb 28, 2026
42cc57c
refactor: move aria label
MatteoGabriele Feb 28, 2026
eea6772
refactor: add table column skeleton
MatteoGabriele Feb 28, 2026
50d66d5
feat: add aria label
MatteoGabriele Feb 28, 2026
15c4046
Merge branch 'main' into feat/action-bar
MatteoGabriele Feb 28, 2026
6adeea2
feat: reset on compare
MatteoGabriele Mar 1, 2026
7554b12
refactor: increase discoverability of action bar
MatteoGabriele Mar 1, 2026
e073a7f
feat: add persisted state via url
MatteoGabriele Mar 1, 2026
6a9b1d5
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 1, 2026
2ae487a
fix: remove clear on click
MatteoGabriele Mar 1, 2026
da70124
Merge branch 'feat/action-bar' of https://github.com/MatteoGabriele/n…
MatteoGabriele Mar 1, 2026
70f7615
feat: add title for disabled input
MatteoGabriele Mar 1, 2026
b972f7b
feat: use tooltip over disabled input
MatteoGabriele Mar 1, 2026
42c5b6a
Merge branch 'main' into feat/action-bar
MatteoGabriele Mar 1, 2026
4a746d6
fix: a11y test
MatteoGabriele Mar 1, 2026
33c43ee
Merge branch 'feat/action-bar' of https://github.com/MatteoGabriele/n…
MatteoGabriele Mar 1, 2026
2e6301a
fix: scroll top missing on search
MatteoGabriele Mar 1, 2026
80fa7ca
Merge branch 'main' into feat/action-bar
MatteoGabriele Mar 1, 2026
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
84 changes: 84 additions & 0 deletions app/components/Package/ActionBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<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-2xl shadow-accent/20 border-2 border-accent/60 p-3 min-w-[300px] rounded-xl flex gap-3 items-center justify-between animate-in ring-1 ring-accent/30"
>
<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-2">
<span class="text-fg font-semibold text-sm flex items-center gap-1.5">
<i class="i-ph:check-circle-fill text-accent text-base" aria-hidden="true"></i>
{{ $t('action_bar.selection', selectedPackages.length) }}
</span>
<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"
>
<span class="i-lucide:x text-sm" 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>
53 changes: 23 additions & 30 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, isMaxSelected } = usePackageSelection()
const isSelected = computed<boolean>(() => {
return isPackageSelected(props.result.package.name)
})

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,17 @@ 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>
</div>
<div class="flex justify-start items-start gap-4 sm:gap-8">
<div class="min-w-0">

<PackageSelectionCheckbox
:package-name="result.package.name"
:disabled="isMaxSelected && !isSelected"
:checked="isSelected"
@change="togglePackageSelection"
/>
</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 +118,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 +143,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"
>
Comment on lines 144 to 147
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"
>

<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
23 changes: 23 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,26 @@ function getSortKeyLabelKey(key: SortKey): string {

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

<div
class="flex items-center order-3 sm:border-is sm:border-fg-subtle/20 sm: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"
aria-label="Close action bar"
class="flex items-center ms-2"
>
<span class="i-lucide:x text-sm" />
</button>
</div>
</div>
</div>

Expand Down
43 changes: 43 additions & 0 deletions app/components/Package/SelectionCheckbox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
const props = defineProps<{
packageName: string
checked: boolean
disabled?: boolean
}>()

const emit = defineEmits<{
(e: 'change', packageName: string): void
}>()

const { t } = useI18n()
const disabledText = t('package.card.select_maximum', MAX_PACKAGE_SELECTION)
</script>

<template>
<div class="relative z-1">
<label>
<span class="sr-only" v-if="disabled">{{ disabledText }}</span>
<span class="sr-only" v-else> {{ $t('package.card.select') }}: {{ packageName }} </span>

<TooltipApp v-if="disabled" :text="disabledText" position="top">
<input
class="opacity-0 group-hover:opacity-100 size-4 accent-accent border border-fg-muted/30 hover:cursor-not-allowed"
:class="{ 'opacity-100! disabled:opacity-30!': isTouchDevice() }"
type="checkbox"
:disabled
/>
</TooltipApp>

<input
v-else
data-package-card-checkbox
class="opacity-0 group-focus-within:opacity-100 checked:opacity-100 group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors disabled:group-hover:opacity-30 disabled:hover:cursor-not-allowed"
:class="{ 'opacity-100! disabled:opacity-30!': isTouchDevice() }"
type="checkbox"
:checked
:disabled
@change="emit('change', packageName)"
/>
</label>
</div>
</template>
71 changes: 71 additions & 0 deletions app/components/Package/SelectionView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script setup lang="ts">
defineProps<{
viewMode?: ViewMode
}>()

const { selectedPackages, clearSelectedPackages, selectedPackagesParam, closeSelectionView } =
usePackageSelection()

const { data, pending } = useAsyncData(
async () => {
const results = await Promise.all(
selectedPackages.value.map(name =>
$fetch(`/api/registry/package-meta/${encodeURIComponent(name)}`)
.then(response => ({ package: response }))
.catch(() => []),
),
)
return results as NpmSearchResult[]
},
{
default: () => [],
},
)
</script>

<template>
<section>
<header class="mb-6 flex items-center justify-between">
<button
type="button"
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70"
@click="closeSelectionView"
:aria-label="$t('nav.back')"
>
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
<span class="hidden sm:inline">{{ $t('nav.back') }}</span>
</button>
<div class="flex items-center gap-2">
<ButtonBase variant="secondary" @click="clearSelectedPackages">
{{ $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">
<div v-if="pending" class="flex items-center justify-center py-12">
<LoadingSpinner :text="$t('common.loading')" />
</div>
<PackageList
v-else-if="data?.length"
:view-mode="viewMode"
:results="data"
heading-level="h2"
/>
<p v-else class="text-fg-muted text-sm">
{{ $t('filters.table.no_packages') }}
</p>
</div>
</section>
</template>
Loading
Loading