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