Skip to content

Commit a880463

Browse files
feat(ui): add action bar
1 parent 1e1fe41 commit a880463

12 files changed

Lines changed: 222 additions & 29 deletions

File tree

app/components/BaseCard.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
defineProps<{
33
/** Whether this is an exact match for the query */
44
isExactMatch?: boolean
5+
selected?: boolean
56
}>()
67
</script>
78

@@ -10,6 +11,7 @@ defineProps<{
1011
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"
1112
:class="{
1213
'border-accent/30 contrast-more:border-accent/90 bg-accent/5': isExactMatch,
14+
'bg-fg-subtle/15!': selected,
1315
}"
1416
>
1517
<!-- Glow effect for exact matches -->
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<script setup lang="ts">
2+
const { selectedPackages, clearSelectedPackages } = usePackageSelection()
3+
const comparePackagesQuery = computed<string>(() =>
4+
selectedPackages.value.map(pkg => pkg.package.name).join(','),
5+
)
6+
7+
const shortcutKey = 'b'
8+
const actionBar = useTemplateRef('actionBarRef')
9+
onKeyStroke(
10+
e => {
11+
const target = e.target as HTMLElement
12+
const isCheckbox = target.hasAttribute('data-package-card-checkbox')
13+
return isKeyWithoutModifiers(e, shortcutKey) && (!isEditableElement(target) || isCheckbox)
14+
},
15+
e => {
16+
e.preventDefault()
17+
actionBar.value?.focus()
18+
},
19+
)
20+
</script>
21+
22+
<template>
23+
<Transition name="action-bar-slide" appear>
24+
<div
25+
v-if="selectedPackages.length"
26+
class="fixed bottom-12 inset-is-0 w-full flex items-center justify-center z-36 pointer-events-none"
27+
>
28+
<div
29+
ref="actionBarRef"
30+
tabindex="-1"
31+
aria-keyshortcuts="b"
32+
class="pointer-events-auto bg-bg shadow-2xl border border-fg-muted/20 p-2.5 min-w-[280px] rounded-xl flex gap-2 items-center justify-between animate-in"
33+
>
34+
<div aria-live="polite" aria-atomic="true" class="sr-only">
35+
{{ $t('action_bar.selection', selectedPackages.length) }}.
36+
{{ $t('action_bar.shortcut', { key: shortcutKey }) }}.
37+
</div>
38+
39+
<div class="flex items-center gap-1 ms-2">
40+
<span class="text-fg text-sm">
41+
{{ $t('action_bar.selection', selectedPackages.length) }}
42+
</span>
43+
<button @click="clearSelectedPackages" class="flex items-center ms-2 hover:text-fg-muted">
44+
<span class="i-lucide:x text-sm" aria-label="Close action bar" />
45+
</button>
46+
</div>
47+
48+
<span class="w-px h-8 bg-fg-subtle/40" />
49+
50+
<div>
51+
<LinkBase
52+
:to="{ name: 'compare', query: { packages: comparePackagesQuery } }"
53+
variant="button-secondary"
54+
classicon="i-lucide:git-compare"
55+
>
56+
Compare
57+
</LinkBase>
58+
</div>
59+
</div>
60+
</div>
61+
</Transition>
62+
</template>
63+
64+
<style scoped>
65+
/* Action bar slide/fade animation */
66+
.action-bar-slide-enter-active,
67+
.action-bar-slide-leave-active {
68+
transition:
69+
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
70+
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
71+
}
72+
.action-bar-slide-enter-from,
73+
.action-bar-slide-leave-to {
74+
opacity: 0;
75+
transform: translateY(40px) scale(0.98);
76+
}
77+
.action-bar-slide-enter-to,
78+
.action-bar-slide-leave-from {
79+
opacity: 1;
80+
transform: translateY(0) scale(1);
81+
}
82+
</style>

app/components/Package/Card.vue

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ const props = defineProps<{
1414
filters?: StructuredFilters
1515
/** Search query for highlighting exact matches */
1616
searchQuery?: string
17+
/** Shows checkbox to all cards and the click will work as selection */
18+
forceSelection?: boolean
1719
}>()
1820
21+
const selected = defineModel<boolean>('selected', {
22+
default: false,
23+
})
24+
1925
const emit = defineEmits<{
2026
clickKeyword: [keyword: string]
2127
}>()
@@ -39,13 +45,23 @@ const numberFormatter = useNumberFormatter()
3945
</script>
4046

4147
<template>
42-
<BaseCard :isExactMatch="isExactMatch">
43-
<div class="mb-2 flex items-baseline justify-start gap-2">
48+
<BaseCard :selected :isExactMatch="isExactMatch">
49+
<header class="mb-4 flex items-baseline justify-between gap-2">
4450
<component
4551
:is="headingLevel ?? 'h3'"
4652
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"
4753
>
54+
<button
55+
v-if="forceSelection"
56+
@click="selected = !selected"
57+
class="after:content-[''] after:absolute after:inset-0 cursor-pointer"
58+
:data-result-index="index"
59+
dir="ltr"
60+
>
61+
{{ result.package.name }}
62+
</button>
4863
<NuxtLink
64+
v-else
4965
:to="packageRoute(result.package.name)"
5066
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
5167
class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0"
@@ -59,28 +75,23 @@ const numberFormatter = useNumberFormatter()
5975
>{{ $t('search.exact_match') }}</span
6076
>
6177
</component>
62-
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
63-
<!-- Mobile: version next to package name -->
64-
<div class="sm:hidden text-fg-subtle flex items-center gap-1.5 shrink-0">
65-
<span
66-
v-if="result.package.version"
67-
class="font-mono text-xs truncate max-w-20"
68-
:title="result.package.version"
69-
>
70-
v{{ result.package.version }}
71-
</span>
72-
<ProvenanceBadge
73-
v-if="result.package.publisher?.trustedPublisher"
74-
:provider="result.package.publisher.trustedPublisher.id"
75-
:package-name="result.package.name"
76-
:version="result.package.version"
77-
:linked="false"
78-
compact
79-
/>
78+
79+
<div class="flex items-center gap-4">
80+
<div class="relative z-1">
81+
<input
82+
data-package-card-checkbox
83+
class="md:opacity-0 group-focus-within:opacity-100 checked:opacity-100 md:group-hover:opacity-100 size-4 cursor-pointer accent-accent border border-fg-muted/30 hover:border-accent transition-colors"
84+
:class="{ 'opacity-100!': forceSelection }"
85+
type="checkbox"
86+
:checked="!!selected"
87+
v-model="selected"
88+
/>
89+
</div>
8090
</div>
81-
</div>
82-
<div class="flex justify-start items-start gap-4 sm:gap-8">
83-
<div class="min-w-0">
91+
</header>
92+
93+
<div class="flex flex-col sm:flex-row sm:justify-start sm:items-start gap-6 sm:gap-8">
94+
<div class="min-w-0 w-full">
8495
<p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3">
8596
<span v-html="pkgDescription" />
8697
</p>
@@ -124,10 +135,9 @@ const numberFormatter = useNumberFormatter()
124135
</div>
125136
</dl>
126137
</div>
127-
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
128-
<!-- Desktop: version and downloads on right side -->
129-
<div class="hidden sm:flex flex-col gap-2 shrink-0">
130-
<div class="text-fg-subtle flex items-start gap-2 justify-end">
138+
139+
<div class="flex flex-col gap-2 shrink-0">
140+
<div class="text-fg-subtle flex items-start gap-2 sm:justify-end">
131141
<span
132142
v-if="result.package.version"
133143
class="font-mono text-xs truncate max-w-32"
@@ -150,7 +160,7 @@ const numberFormatter = useNumberFormatter()
150160
</div>
151161
<div
152162
v-if="result.downloads?.weekly"
153-
class="text-fg-subtle gap-2 flex items-center justify-end"
163+
class="text-fg-subtle gap-2 flex items-center sm:justify-end"
154164
>
155165
<span class="i-lucide:chart-line w-3.5 h-3.5" aria-hidden="true" />
156166
<span class="font-mono text-xs">

app/components/Package/List.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,14 @@ function scrollToIndex(index: number, smooth = true) {
147147
defineExpose({
148148
scrollToIndex,
149149
})
150+
151+
const { selectedPackages, isPackageSelected, togglePackageSelection } = usePackageSelection()
150152
</script>
151153

152154
<template>
153155
<div>
156+
<PackageActionBar />
157+
154158
<!-- Table View -->
155159
<template v-if="viewMode === 'table'">
156160
<PackageTable
@@ -179,14 +183,17 @@ defineExpose({
179183
<template #default="{ item, index }">
180184
<div class="pb-4">
181185
<PackageCard
182-
:result="item as NpmSearchResult"
186+
:result="item"
183187
:heading-level="headingLevel"
184188
:show-publisher="showPublisher"
185189
:index="index"
186190
:search-query="searchQuery"
187191
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
188192
:filters="filters"
189193
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
194+
:selected="isPackageSelected(item)"
195+
:force-selection="selectedPackages.length > 0"
196+
@update:selected="togglePackageSelection(item)"
190197
@click-keyword="emit('clickKeyword', $event)"
191198
/>
192199
</div>

app/components/Package/ListToolbar.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const pageSize = defineModel<PageSize>('pageSize', { required: true })
4343
4444
const emit = defineEmits<{
4545
'toggleColumn': [columnId: ColumnId]
46+
'toggleSelection': []
4647
'resetColumns': []
4748
'clearFilter': [chip: FilterChip]
4849
'clearAllFilters': []
@@ -110,6 +111,8 @@ const sortKeyLabelKeys = computed<Record<SortKey, string>>(() => ({
110111
function getSortKeyLabelKey(key: SortKey): string {
111112
return sortKeyLabelKeys.value[key]
112113
}
114+
115+
const { selectedPackages, clearSelectedPackages } = usePackageSelection()
113116
</script>
114117

115118
<template>
@@ -227,6 +230,22 @@ function getSortKeyLabelKey(key: SortKey): string {
227230
@reset="emit('resetColumns')"
228231
/>
229232
</div>
233+
234+
<div
235+
class="flex items-center order-3 border-is border-fg-subtle/20 ps-3"
236+
v-if="selectedPackages.length"
237+
>
238+
<LinkBase
239+
to="/package-selection"
240+
variant="button-secondary"
241+
classicon="i-lucide:package-check"
242+
>
243+
View selected ({{ selectedPackages.length }})
244+
</LinkBase>
245+
<button @click="clearSelectedPackages" class="flex items-center ms-2">
246+
<span class="i-lucide:x text-sm" aria-label="Close action bar" />
247+
</button>
248+
</div>
230249
</div>
231250
</div>
232251

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export function usePackageSelection() {
2+
const selectedPackages = useState<NpmSearchResult[]>('selected_packages', () => [])
3+
4+
function findPackageIndex(pkg: NpmSearchResult): number {
5+
return selectedPackages.value.findIndex(
6+
selectedPackage => selectedPackage.package.name === pkg.package.name,
7+
)
8+
}
9+
10+
function isPackageSelected(pkg: NpmSearchResult): boolean {
11+
return findPackageIndex(pkg) !== -1
12+
}
13+
14+
function togglePackageSelection(pkg: NpmSearchResult) {
15+
const itemIndex = findPackageIndex(pkg)
16+
17+
if (itemIndex !== -1) {
18+
selectedPackages.value.splice(itemIndex, 1)
19+
} else {
20+
selectedPackages.value.push(pkg)
21+
}
22+
}
23+
24+
function clearSelectedPackages() {
25+
selectedPackages.value = []
26+
}
27+
28+
return {
29+
selectedPackages,
30+
clearSelectedPackages,
31+
isPackageSelected,
32+
togglePackageSelection,
33+
}
34+
}

app/pages/package-selection.vue

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script setup lang="ts"></script>
2+
3+
<template>
4+
<!-- <div class="container-sm w-full py-6">Package selection page</div> -->
5+
6+
<main class="flex-1 py-8">
7+
<div class="container-sm">
8+
<div class="flex items-center justify-between gap-4 mb-4">
9+
<h1 class="font-mono text-2xl sm:text-3xl font-medium">selected packages</h1>
10+
<SearchProviderToggle />
11+
</div>
12+
</div>
13+
</main>
14+
</template>

i18n/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,5 +1195,9 @@
11951195
"all": "all"
11961196
}
11971197
}
1198+
},
1199+
"action_bar": {
1200+
"selection": "0 selected | 1 selected | {count} selected",
1201+
"shortcut": "Press \"{key}\" to focus actions"
11981202
}
11991203
}

i18n/schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3592,6 +3592,18 @@
35923592
},
35933593
"additionalProperties": false
35943594
},
3595+
"action_bar": {
3596+
"type": "object",
3597+
"properties": {
3598+
"selection": {
3599+
"type": "string"
3600+
},
3601+
"shortcut": {
3602+
"type": "string"
3603+
}
3604+
},
3605+
"additionalProperties": false
3606+
},
35953607
"$schema": {
35963608
"type": "string"
35973609
}

lunaria/files/en-GB.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,5 +1160,9 @@
11601160
"p1": "If you encounter an accessibility barrier on {app}, please let us know by opening an issue on our {link}. We take these reports seriously and will do our best to address them.",
11611161
"link": "GitHub repository"
11621162
}
1163+
},
1164+
"action_bar": {
1165+
"selection": "0 selected | 1 selected | {count} selected",
1166+
"shortcut": "Press \"{key}\" to focus actions"
11631167
}
11641168
}

0 commit comments

Comments
 (0)