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
87 changes: 87 additions & 0 deletions app/components/Package/ActionBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<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>
<section
v-if="selectedPackages.length"
aria-labelledby="action-bar-title"
class="group fixed bottom-10 inset-is-0 w-full flex items-center justify-center z-36 pointer-events-none focus:outline-none"
tabindex="-1"
aria-keyshortcuts="b"
ref="actionBarRef"
>
<h3 id="action-bar-title" class="sr-only">
{{ $t('action_bar.title') }}
</h3>
<div
class="group-focus:outline-accent group-focus:outline-2 group-focus:outline-offset-2 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">
{{ $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="$t('action_bar.button_close_aria_label')"
>
<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>
</section>
</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, canSelectMore } = 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="!canSelectMore && !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"
>
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
<span class="font-mono text-xs">
Expand Down
12 changes: 6 additions & 6 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ const likeAction = async () => {

<template>
<!-- Package header -->
<header class="bg-bg pt-5 w-full container">
<header class="bg-bg pt-5 pb-1 w-full container">
<!-- Package name and version -->
<div class="flex items-baseline justify-between gap-x-2 gap-y-1 flex-wrap min-w-0">
<CopyToClipboardButton
Expand Down Expand Up @@ -319,7 +319,7 @@ const likeAction = async () => {
</header>
<div
ref="header"
class="w-full bg-bg sticky top-14 z-50 border-b border-border pt-2"
class="w-full bg-bg sticky top-14 z-10 border-b border-border pt-2"
:class="[$style.packageHeader]"
data-testid="package-subheader"
>
Expand Down Expand Up @@ -395,7 +395,7 @@ const likeAction = async () => {
v-if="mainLink"
:to="mainLink"
aria-keyshortcuts="m"
class="decoration-none border-b-2 p-1 hover:border-accent/50 lowercase"
class="decoration-none border-b-2 p-1 hover:border-accent/50 lowercase focus-visible:[outline-offset:-2px]!"
:class="page === 'main' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.main') }}
Expand All @@ -404,7 +404,7 @@ const likeAction = async () => {
v-if="docsLink"
:to="docsLink"
aria-keyshortcuts="d"
class="decoration-none border-b-2 p-1 hover:border-accent/50"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'docs' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.docs') }}
Expand All @@ -413,7 +413,7 @@ const likeAction = async () => {
v-if="codeLink"
:to="codeLink"
aria-keyshortcuts="."
class="decoration-none border-b-2 p-1 hover:border-accent/50"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'code' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.code') }}
Expand All @@ -423,7 +423,7 @@ const likeAction = async () => {
:to="diffLink"
:title="$t('compare.compare_versions_title')"
aria-keyshortcuts="f"
class="decoration-none border-b-2 p-1 hover:border-accent/50"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'diff' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('compare.compare_versions') }}
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 @@ -166,7 +166,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>
Loading
Loading