Skip to content

Commit 5a37ccb

Browse files
garthdwjhroemerknowlerdanielroejellydeck
authored
feat: add collapsible section component (#488)
Co-authored-by: Jens Rømer Hesselbjerg <jh.roemer@gmail.com> Co-authored-by: Nathan Knowler <nathan@knowler.dev> Co-authored-by: Daniel Roe <daniel@roe.dev> Co-authored-by: jellydeck <91427591+jellydeck@users.noreply.github.com> Co-authored-by: jyc.dev <jycouet@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 53cf774 commit 5a37ccb

7 files changed

Lines changed: 187 additions & 111 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
4+
interface Props {
5+
title: string
6+
isLoading?: boolean
7+
headingLevel?: `h${number}`
8+
id: string
9+
}
10+
11+
const props = withDefaults(defineProps<Props>(), {
12+
isLoading: false,
13+
headingLevel: 'h2',
14+
})
15+
16+
const appSettings = useSettings()
17+
18+
const buttonId = `${props.id}-collapsible-button`
19+
const contentId = `${props.id}-collapsible-content`
20+
const headingId = `${props.id}-heading`
21+
22+
const isOpen = ref(true)
23+
24+
onPrehydrate(() => {
25+
const settings = JSON.parse(localStorage.getItem('npmx-settings') || '{}')
26+
const collapsed: string[] = settings?.sidebar?.collapsed || []
27+
for (const id of collapsed) {
28+
if (!document.documentElement.dataset.collapsed?.includes(id)) {
29+
document.documentElement.dataset.collapsed = (
30+
document.documentElement.dataset.collapsed +
31+
' ' +
32+
id
33+
).trim()
34+
}
35+
}
36+
})
37+
38+
onMounted(() => {
39+
if (document?.documentElement) {
40+
isOpen.value = !(document.documentElement.dataset.collapsed?.includes(props.id) ?? false)
41+
}
42+
})
43+
44+
function toggle() {
45+
isOpen.value = !isOpen.value
46+
47+
const removed = appSettings.settings.value.sidebar.collapsed.filter(c => c !== props.id)
48+
49+
if (isOpen.value) {
50+
appSettings.settings.value.sidebar.collapsed = removed
51+
} else {
52+
removed.push(props.id)
53+
appSettings.settings.value.sidebar.collapsed = removed
54+
}
55+
56+
document.documentElement.dataset.collapsed =
57+
appSettings.settings.value.sidebar.collapsed.join(' ')
58+
}
59+
60+
const ariaLabel = computed(() => {
61+
const action = isOpen.value ? 'Collapse' : 'Expand'
62+
return props.title ? `${action} ${props.title}` : action
63+
})
64+
useHead({
65+
style: [
66+
{
67+
innerHTML: `
68+
:root[data-collapsed~='${props.id}'] section[data-anchor-id='${props.id}'] .collapsible-content {
69+
grid-template-rows: 0fr;
70+
}`,
71+
},
72+
],
73+
})
74+
</script>
75+
76+
<template>
77+
<section class="scroll-mt-20" :data-anchor-id="id">
78+
<div class="flex items-center justify-between mb-3">
79+
<component
80+
:is="headingLevel"
81+
:id="headingId"
82+
class="group text-xs text-fg-subtle uppercase tracking-wider flex items-center gap-2"
83+
>
84+
<button
85+
:id="buttonId"
86+
type="button"
87+
class="w-4 h-4 flex items-center justify-center text-fg-subtle hover:text-fg-muted transition-colors duration-200 shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
88+
:aria-expanded="isOpen"
89+
:aria-controls="contentId"
90+
:aria-label="ariaLabel"
91+
@click="toggle"
92+
>
93+
<span
94+
v-if="isLoading"
95+
class="i-carbon:rotate-180 w-3 h-3 motion-safe:animate-spin"
96+
aria-hidden="true"
97+
/>
98+
<span
99+
v-else
100+
class="w-3 h-3 transition-transform duration-200"
101+
:class="isOpen ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right'"
102+
aria-hidden="true"
103+
/>
104+
</button>
105+
106+
<a
107+
:href="`#${id}`"
108+
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
109+
>
110+
{{ title }}
111+
<span
112+
class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
113+
aria-hidden="true"
114+
/>
115+
</a>
116+
</component>
117+
118+
<!-- Actions slot for buttons or other elements -->
119+
<slot name="actions" />
120+
</div>
121+
122+
<div
123+
:id="contentId"
124+
class="grid ms-6 transition-[grid-template-rows] duration-200 ease-in-out collapsible-content overflow-hidden"
125+
>
126+
<div class="min-h-0">
127+
<slot />
128+
</div>
129+
</div>
130+
</section>
131+
</template>

app/components/PackageDependencies.vue

Lines changed: 18 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -70,22 +70,11 @@ const sortedOptionalDependencies = computed(() => {
7070
<template>
7171
<div class="space-y-8">
7272
<!-- Dependencies -->
73-
<section id="dependencies" v-if="sortedDependencies.length > 0" class="scroll-mt-20">
74-
<h2
75-
id="dependencies-heading"
76-
class="group text-xs text-fg-subtle uppercase tracking-wider mb-3"
77-
>
78-
<a
79-
href="#dependencies"
80-
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
81-
>
82-
{{ $t('package.dependencies.title', { count: sortedDependencies.length }) }}
83-
<span
84-
class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
85-
aria-hidden="true"
86-
/>
87-
</a>
88-
</h2>
73+
<CollapsibleSection
74+
v-if="sortedDependencies.length > 0"
75+
id="dependencies"
76+
:title="$t('package.dependencies.title', { count: sortedDependencies.length })"
77+
>
8978
<ul class="space-y-1 list-none m-0 p-0" :aria-label="$t('package.dependencies.list_label')">
9079
<li
9180
v-for="[dep, version] in sortedDependencies.slice(0, depsExpanded ? undefined : 10)"
@@ -151,33 +140,14 @@ const sortedOptionalDependencies = computed(() => {
151140
</span>
152141
</li>
153142
</ul>
154-
<button
155-
v-if="sortedDependencies.length > 10 && !depsExpanded"
156-
type="button"
157-
class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
158-
@click="depsExpanded = true"
159-
>
160-
{{ $t('package.dependencies.show_all', { count: sortedDependencies.length }) }}
161-
</button>
162-
</section>
143+
</CollapsibleSection>
163144

164145
<!-- Peer Dependencies -->
165-
<section id="peer-dependencies" v-if="sortedPeerDependencies.length > 0" class="scroll-mt-20">
166-
<h2
167-
id="peer-dependencies-heading"
168-
class="group text-xs text-fg-subtle uppercase tracking-wider mb-3"
169-
>
170-
<a
171-
href="#peer-dependencies"
172-
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
173-
>
174-
{{ $t('package.peer_dependencies.title', { count: sortedPeerDependencies.length }) }}
175-
<span
176-
class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
177-
aria-hidden="true"
178-
/>
179-
</a>
180-
</h2>
146+
<CollapsibleSection
147+
v-if="sortedPeerDependencies.length > 0"
148+
id="peer-dependencies"
149+
:title="$t('package.peer_dependencies.title', { count: sortedPeerDependencies.length })"
150+
>
181151
<ul
182152
class="space-y-1 list-none m-0 p-0"
183153
:aria-label="$t('package.peer_dependencies.list_label')"
@@ -223,31 +193,16 @@ const sortedOptionalDependencies = computed(() => {
223193
>
224194
{{ $t('package.peer_dependencies.show_all', { count: sortedPeerDependencies.length }) }}
225195
</button>
226-
</section>
196+
</CollapsibleSection>
227197

228198
<!-- Optional Dependencies -->
229-
<section
230-
id="optional-dependencies"
199+
<CollapsibleSection
231200
v-if="sortedOptionalDependencies.length > 0"
232-
class="scroll-mt-20"
201+
id="optional-dependencies"
202+
:title="
203+
$t('package.optional_dependencies.title', { count: sortedOptionalDependencies.length })
204+
"
233205
>
234-
<h2
235-
id="optional-dependencies-heading"
236-
class="group text-xs text-fg-subtle uppercase tracking-wider mb-3"
237-
>
238-
<a
239-
href="#optional-dependencies"
240-
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
241-
>
242-
{{
243-
$t('package.optional_dependencies.title', { count: sortedOptionalDependencies.length })
244-
}}
245-
<span
246-
class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
247-
aria-hidden="true"
248-
/>
249-
</a>
250-
</h2>
251206
<ul
252207
class="space-y-1 list-none m-0 p-0"
253208
:aria-label="$t('package.optional_dependencies.list_label')"
@@ -286,6 +241,6 @@ const sortedOptionalDependencies = computed(() => {
286241
$t('package.optional_dependencies.show_all', { count: sortedOptionalDependencies.length })
287242
}}
288243
</button>
289-
</section>
244+
</CollapsibleSection>
290245
</div>
291246
</template>

app/components/PackageVersions.vue

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
<script setup lang="ts">
2-
import type { PackumentVersion, PackageVersionInfo } from '#shared/types'
3-
import type { RouteLocationRaw } from 'vue-router'
2+
import type { PackageVersionInfo, PackumentVersion } from '#shared/types'
43
import { compare } from 'semver'
4+
import type { RouteLocationRaw } from 'vue-router'
5+
import { fetchAllPackageVersions } from '~/composables/useNpmRegistry'
56
import {
67
buildVersionToTagsMap,
78
filterExcludedTags,
89
getPrereleaseChannel,
910
getVersionGroupKey,
1011
getVersionGroupLabel,
1112
isSameVersionGroup,
12-
parseVersion,
1313
} from '~/utils/versions'
14-
import { fetchAllPackageVersions } from '~/composables/useNpmRegistry'
1514
1615
const props = defineProps<{
1716
packageName: string
@@ -312,20 +311,11 @@ function getTagVersions(tag: string): VersionDisplay[] {
312311
</script>
313312

314313
<template>
315-
<section id="versions" v-if="allTagRows.length > 0" class="overflow-hidden scroll-mt-20">
316-
<h2 id="versions-heading" class="group text-xs text-fg-subtle uppercase tracking-wider mb-3">
317-
<a
318-
href="#versions"
319-
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
320-
>
321-
{{ $t('package.versions.title') }}
322-
<span
323-
class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
324-
aria-hidden="true"
325-
/>
326-
</a>
327-
</h2>
328-
314+
<CollapsibleSection
315+
v-if="allTagRows.length > 0"
316+
:title="$t('package.versions.title')"
317+
id="versions"
318+
>
329319
<div class="space-y-0.5 min-w-0">
330320
<!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) -->
331321
<div v-for="row in visibleTagRows" :key="row.id">
@@ -344,6 +334,7 @@ function getTagVersions(tag: string): VersionDisplay[] {
344334
? $t('package.versions.collapse', { tag: row.tag })
345335
: $t('package.versions.expand', { tag: row.tag })
346336
"
337+
data-testid="tag-expand-button"
347338
@click="expandTagRow(row.tag)"
348339
>
349340
<span
@@ -593,6 +584,7 @@ function getTagVersions(tag: string): VersionDisplay[] {
593584
? $t('package.versions.collapse_major', { major: group.label })
594585
: $t('package.versions.expand_major', { major: group.label })
595586
"
587+
data-testid="major-group-expand-button"
596588
@click="toggleMajorGroup(group.groupKey)"
597589
>
598590
<span
@@ -786,5 +778,5 @@ function getTagVersions(tag: string): VersionDisplay[] {
786778
</div>
787779
</div>
788780
</div>
789-
</section>
781+
</CollapsibleSection>
790782
</template>

app/components/PackageWeeklyDownloadStats.vue

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,20 +191,8 @@ const config = computed(() => {
191191

192192
<template>
193193
<div class="space-y-8">
194-
<section id="downloads" class="scroll-mt-20">
195-
<div class="flex items-center justify-between mb-3">
196-
<h2 class="group text-xs text-fg-subtle uppercase tracking-wider">
197-
<a
198-
href="#downloads"
199-
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
200-
>
201-
{{ $t('package.downloads.title') }}
202-
<span
203-
class="i-carbon:link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
204-
aria-hidden="true"
205-
/>
206-
</a>
207-
</h2>
194+
<CollapsibleSection id="downloads" :title="$t('package.downloads.title')">
195+
<template #actions>
208196
<button
209197
type="button"
210198
@click="showModal = true"
@@ -214,7 +202,7 @@ const config = computed(() => {
214202
<span class="i-carbon:data-analytics w-4 h-4" aria-hidden="true" />
215203
<span class="sr-only">{{ $t('package.downloads.analyze') }}</span>
216204
</button>
217-
</div>
205+
</template>
218206

219207
<div class="w-full overflow-hidden">
220208
<ClientOnly>
@@ -251,7 +239,7 @@ const config = computed(() => {
251239
</template>
252240
</ClientOnly>
253241
</div>
254-
</section>
242+
</CollapsibleSection>
255243
</div>
256244

257245
<ChartModal v-model:open="showModal">

app/composables/useSettings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@ export interface AppSettings {
1616
accentColorId: AccentColorId | null
1717
/** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */
1818
hidePlatformPackages: boolean
19+
sidebar: {
20+
collapsed: string[]
21+
}
1922
}
2023

2124
const DEFAULT_SETTINGS: AppSettings = {
2225
relativeDates: false,
2326
includeTypesInInstall: true,
2427
accentColorId: null,
2528
hidePlatformPackages: true,
29+
sidebar: {
30+
collapsed: [],
31+
},
2632
}
2733

2834
const STORAGE_KEY = 'npmx-settings'

app/utils/prehydrate.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,7 @@ export function initPreferencesOnPrehydrate() {
5656

5757
// Set data attribute for CSS-based visibility
5858
document.documentElement.dataset.pm = pm
59+
60+
document.documentElement.dataset.collapsed = settings.sidebar?.collapsed?.join(' ') ?? ''
5961
})
6062
}

0 commit comments

Comments
 (0)