Skip to content

Commit 4b5a2b1

Browse files
committed
fix: pin the sidebar only when necessary
1 parent 9867a47 commit 4b5a2b1

4 files changed

Lines changed: 127 additions & 41 deletions

File tree

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
@@ -1144,11 +1144,9 @@ onKeyStroke(
11441144
</div>
11451145
</section>
11461146
</section>
1147-
<div class="area-sidebar">
1148-
<!-- Sidebar -->
1149-
<div
1150-
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)]) pt-1"
1151-
>
1147+
1148+
<PackageSidebar class="area-sidebar">
1149+
<div class="flex flex-col gap-4 sm:gap-6 xl:(pt-2)">
11521150
<!-- Team access controls (for scoped packages when connected) -->
11531151
<ClientOnly>
11541152
<PackageAccessControls :package-name="pkg.name" />
@@ -1208,7 +1206,7 @@ onKeyStroke(
12081206
<!-- Maintainers (with admin actions when connected) -->
12091207
<PackageMaintainers :package-name="pkg.name" :maintainers="pkg.maintainers" />
12101208
</div>
1211-
</div>
1209+
</PackageSidebar>
12121210
</article>
12131211

12141212
<!-- Error state -->
@@ -1302,41 +1300,6 @@ onKeyStroke(
13021300
grid-area: sidebar;
13031301
}
13041302
1305-
/* Sidebar scrollbar: hidden by default, shown on hover/focus */
1306-
@media (min-width: 1024px) {
1307-
.sidebar-scroll {
1308-
scrollbar-gutter: stable;
1309-
scrollbar-width: 8px;
1310-
scrollbar-color: transparent transparent;
1311-
}
1312-
1313-
.sidebar-scroll::-webkit-scrollbar {
1314-
width: 8px;
1315-
height: 8px;
1316-
}
1317-
1318-
.sidebar-scroll::-webkit-scrollbar-track,
1319-
.sidebar-scroll::-webkit-scrollbar-thumb {
1320-
background: transparent;
1321-
}
1322-
1323-
.sidebar-scroll:hover,
1324-
.sidebar-scroll:focus-within {
1325-
scrollbar-color: var(--border) transparent;
1326-
}
1327-
1328-
.sidebar-scroll:hover::-webkit-scrollbar-thumb,
1329-
.sidebar-scroll:focus-within::-webkit-scrollbar-thumb {
1330-
background-color: var(--border);
1331-
border-radius: 9999px;
1332-
}
1333-
1334-
.sidebar-scroll:hover::-webkit-scrollbar-track,
1335-
.sidebar-scroll:focus-within::-webkit-scrollbar-track {
1336-
background: transparent;
1337-
}
1338-
}
1339-
13401303
/* Improve package name wrapping for narrow screens */
13411304
.area-header h1 {
13421305
overflow-wrap: anywhere;

test/nuxt/a11y.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import {
127127
PackageMetricsBadges,
128128
PackagePlaygrounds,
129129
PackageReplacement,
130+
PackageSidebar,
130131
PackageSkeleton,
131132
PackageSkillsCard,
132133
PackageTable,
@@ -2028,6 +2029,18 @@ describe('component accessibility audits', () => {
20282029
})
20292030
})
20302031

2032+
describe('PackageSidebar', () => {
2033+
it('should have no accessibility violations with slot content', async () => {
2034+
const component = await mountSuspended(PackageSidebar, {
2035+
slots: {
2036+
default: () => h('div', 'Sidebar content'),
2037+
},
2038+
})
2039+
const results = await runAxe(component)
2040+
expect(results.violations).toEqual([])
2041+
})
2042+
})
2043+
20312044
describe('PackageSkillsCard', () => {
20322045
it('should have no accessibility violations with skills', async () => {
20332046
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)