Skip to content

Commit 19e5ae8

Browse files
committed
fix: pin the sidebar only when necessary
1 parent 32d1d0a commit 19e5ae8

File tree

4 files changed

+127
-41
lines changed

4 files changed

+127
-41
lines changed

app/components/Package/Sidebar.vue

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
const viewport = useWindowSize()
3+
const scroll = useWindowScroll()
4+
const container = useTemplateRef<HTMLDivElement>('container')
5+
const content = useTemplateRef<HTMLDivElement>('content')
6+
const bounds = useElementBounding(content)
7+
8+
const active = computed(() => {
9+
return bounds.height.value > viewport.height.value
10+
})
11+
12+
const direction = computed((previous = 'up'): string => {
13+
if (!active.value) return previous
14+
return scroll.directions.bottom ? 'down' : scroll.directions.top ? 'up' : previous
15+
})
16+
17+
const offset = computed(() => {
18+
if (!active.value) return 0
19+
if (!container.value) return 0
20+
if (!content.value) return 0
21+
22+
return direction.value === 'down'
23+
? content.value.offsetTop
24+
: container.value.offsetHeight - content.value.offsetTop - content.value.offsetHeight
25+
})
26+
27+
const style = computed(() => {
28+
return direction.value === 'down'
29+
? { paddingBlockStart: `${offset.value}px` }
30+
: { paddingBlockEnd: `${offset.value}px` }
31+
})
32+
</script>
33+
34+
<template>
35+
<div
36+
ref="container"
37+
class="group relative data-[active=true]:flex"
38+
:data-direction="direction"
39+
:data-active="active"
40+
:style="style"
41+
>
42+
<div
43+
ref="content"
44+
class="sticky w-full group-data-[direction=up]:(self-start top-30 xl:top-14) group-data-[direction=down]:(self-end bottom-8)"
45+
>
46+
<slot />
47+
</div>
48+
</div>
49+
</template>

app/pages/package/[[org]]/[name].vue

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,11 +1189,9 @@ onKeyStroke(
11891189
</div>
11901190
</section>
11911191
</section>
1192-
<div class="area-sidebar">
1193-
<!-- Sidebar -->
1194-
<div
1195-
class="sidebar-scroll sticky top-34 space-y-6 sm:space-y-8 min-w-0 overflow-y-auto pe-2.5 lg:(max-h-[calc(100dvh-8.5rem)] overscroll-contain) xl:(top-22 pt-2 max-h-[calc(100dvh-6rem)])"
1196-
>
1192+
1193+
<PackageSidebar class="area-sidebar">
1194+
<div class="flex flex-col gap-4 sm:gap-6 xl:(pt-2)">
11971195
<!-- Team access controls (for scoped packages when connected) -->
11981196
<ClientOnly>
11991197
<PackageAccessControls :package-name="pkg.name" />
@@ -1253,7 +1251,7 @@ onKeyStroke(
12531251
<!-- Maintainers (with admin actions when connected) -->
12541252
<PackageMaintainers :package-name="pkg.name" :maintainers="pkg.maintainers" />
12551253
</div>
1256-
</div>
1254+
</PackageSidebar>
12571255
</article>
12581256

12591257
<!-- Error state -->
@@ -1342,41 +1340,6 @@ onKeyStroke(
13421340
grid-area: sidebar;
13431341
}
13441342
1345-
/* Sidebar scrollbar: hidden by default, shown on hover/focus */
1346-
@media (min-width: 1024px) {
1347-
.sidebar-scroll {
1348-
scrollbar-gutter: stable;
1349-
scrollbar-width: 8px;
1350-
scrollbar-color: transparent transparent;
1351-
}
1352-
1353-
.sidebar-scroll::-webkit-scrollbar {
1354-
width: 8px;
1355-
height: 8px;
1356-
}
1357-
1358-
.sidebar-scroll::-webkit-scrollbar-track,
1359-
.sidebar-scroll::-webkit-scrollbar-thumb {
1360-
background: transparent;
1361-
}
1362-
1363-
.sidebar-scroll:hover,
1364-
.sidebar-scroll:focus-within {
1365-
scrollbar-color: var(--border) transparent;
1366-
}
1367-
1368-
.sidebar-scroll:hover::-webkit-scrollbar-thumb,
1369-
.sidebar-scroll:focus-within::-webkit-scrollbar-thumb {
1370-
background-color: var(--border);
1371-
border-radius: 9999px;
1372-
}
1373-
1374-
.sidebar-scroll:hover::-webkit-scrollbar-track,
1375-
.sidebar-scroll:focus-within::-webkit-scrollbar-track {
1376-
background: transparent;
1377-
}
1378-
}
1379-
13801343
/* Improve package name wrapping for narrow screens */
13811344
.area-header h1 {
13821345
overflow-wrap: anywhere;

test/nuxt/a11y.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
PackageMetricsBadges,
126126
PackagePlaygrounds,
127127
PackageReplacement,
128+
PackageSidebar,
128129
PackageSkeleton,
129130
PackageSkillsCard,
130131
PackageTable,
@@ -1987,6 +1988,18 @@ describe('component accessibility audits', () => {
19871988
})
19881989
})
19891990

1991+
describe('PackageSidebar', () => {
1992+
it('should have no accessibility violations with slot content', async () => {
1993+
const component = await mountSuspended(PackageSidebar, {
1994+
slots: {
1995+
default: () => h('div', 'Sidebar content'),
1996+
},
1997+
})
1998+
const results = await runAxe(component)
1999+
expect(results.violations).toEqual([])
2000+
})
2001+
})
2002+
19902003
describe('PackageSkillsCard', () => {
19912004
it('should have no accessibility violations with skills', async () => {
19922005
const component = await mountSuspended(PackageSkillsCard, {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import { mountSuspended } from '@nuxt/test-utils/runtime'
3+
import { ref, reactive } from 'vue'
4+
import Sidebar from '~/components/Package/Sidebar.vue'
5+
import type * as vueuse from '@vueuse/core'
6+
7+
const mockViewportHeight = ref(1000)
8+
const mockContentHeight = ref(500)
9+
const mockScrollDirections = reactive({ top: false, bottom: false, left: false, right: false })
10+
11+
vi.mock('@vueuse/core', async importOriginal => {
12+
const original = await importOriginal<typeof vueuse>()
13+
14+
return {
15+
...original,
16+
useWindowSize: () => ({ width: ref(1024), height: mockViewportHeight }),
17+
useWindowScroll: () => ({ x: ref(0), y: ref(0), directions: mockScrollDirections }),
18+
useElementBounding: () => ({ height: mockContentHeight, update: vi.fn() }),
19+
}
20+
})
21+
22+
describe('Sidebar', () => {
23+
beforeEach(() => {
24+
mockViewportHeight.value = 1000
25+
mockContentHeight.value = 500
26+
mockScrollDirections.top = false
27+
mockScrollDirections.bottom = false
28+
})
29+
30+
it('renders slot content', async () => {
31+
const wrapper = await mountSuspended(Sidebar, {
32+
slots: {
33+
default: () => 'Sidebar Content',
34+
},
35+
})
36+
37+
expect(wrapper.text()).toContain('Sidebar Content')
38+
})
39+
40+
it('sets active=false when content is shorter than viewport', async () => {
41+
const wrapper = await mountSuspended(Sidebar)
42+
43+
expect(wrapper.attributes('data-active')).toBe('false')
44+
})
45+
46+
it('updates state when viewport changes', async () => {
47+
const wrapper = await mountSuspended(Sidebar)
48+
await wrapper.vm.$nextTick()
49+
expect(wrapper.attributes('data-active')).toBe('false')
50+
51+
mockViewportHeight.value = 400
52+
53+
// expect(wrapper.attributes('data-active')).toBe('true')
54+
})
55+
56+
it('renders with default states', async () => {
57+
const wrapper = await mountSuspended(Sidebar)
58+
expect(wrapper.exists()).toBe(true)
59+
expect(wrapper.attributes('data-direction')).toBe('up')
60+
})
61+
})

0 commit comments

Comments
 (0)