Skip to content

Commit d3800dc

Browse files
authored
fix: pin the sidebar only when necessary (#968)
1 parent 0105bef commit d3800dc

File tree

4 files changed

+114
-41
lines changed

4 files changed

+114
-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 'up'
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
@@ -1153,11 +1153,9 @@ onKeyStroke(
11531153
</div>
11541154
</section>
11551155
</section>
1156-
<div class="area-sidebar">
1157-
<!-- Sidebar -->
1158-
<div
1159-
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"
1160-
>
1156+
1157+
<PackageSidebar class="area-sidebar">
1158+
<div class="flex flex-col gap-4 sm:gap-6 xl:(pt-2)">
11611159
<!-- Team access controls (for scoped packages when connected) -->
11621160
<ClientOnly>
11631161
<PackageAccessControls :package-name="pkg.name" />
@@ -1217,7 +1215,7 @@ onKeyStroke(
12171215
<!-- Maintainers (with admin actions when connected) -->
12181216
<PackageMaintainers :package-name="pkg.name" :maintainers="pkg.maintainers" />
12191217
</div>
1220-
</div>
1218+
</PackageSidebar>
12211219
</article>
12221220

12231221
<!-- Error state -->
@@ -1311,41 +1309,6 @@ onKeyStroke(
13111309
grid-area: sidebar;
13121310
}
13131311
1314-
/* Sidebar scrollbar: hidden by default, shown on hover/focus */
1315-
@media (min-width: 1024px) {
1316-
.sidebar-scroll {
1317-
scrollbar-gutter: stable;
1318-
scrollbar-width: 8px;
1319-
scrollbar-color: transparent transparent;
1320-
}
1321-
1322-
.sidebar-scroll::-webkit-scrollbar {
1323-
width: 8px;
1324-
height: 8px;
1325-
}
1326-
1327-
.sidebar-scroll::-webkit-scrollbar-track,
1328-
.sidebar-scroll::-webkit-scrollbar-thumb {
1329-
background: transparent;
1330-
}
1331-
1332-
.sidebar-scroll:hover,
1333-
.sidebar-scroll:focus-within {
1334-
scrollbar-color: var(--border) transparent;
1335-
}
1336-
1337-
.sidebar-scroll:hover::-webkit-scrollbar-thumb,
1338-
.sidebar-scroll:focus-within::-webkit-scrollbar-thumb {
1339-
background-color: var(--border);
1340-
border-radius: 9999px;
1341-
}
1342-
1343-
.sidebar-scroll:hover::-webkit-scrollbar-track,
1344-
.sidebar-scroll:focus-within::-webkit-scrollbar-track {
1345-
background: transparent;
1346-
}
1347-
}
1348-
13491312
/* Improve package name wrapping for narrow screens */
13501313
.area-header h1 {
13511314
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { afterEach, describe, expect, it } from 'vitest'
2+
import { mountSuspended } from '@nuxt/test-utils/runtime'
3+
import type { VueWrapper } from '@vue/test-utils'
4+
import Sidebar from '~/components/Package/Sidebar.vue'
5+
6+
const VIEWPORT_HEIGHT = window.innerHeight
7+
8+
function mountSidebar(contentHeight?: number) {
9+
return mountSuspended(Sidebar, {
10+
attachTo: document.body,
11+
slots: contentHeight
12+
? { default: () => h('div', { style: `height:${contentHeight}px` }) }
13+
: { default: () => 'Sidebar Content' },
14+
})
15+
}
16+
17+
describe('PackageSidebar', () => {
18+
let wrapper: VueWrapper
19+
20+
afterEach(() => {
21+
wrapper?.unmount()
22+
})
23+
24+
it('renders slot content', async () => {
25+
wrapper = await mountSidebar()
26+
27+
expect(wrapper.text()).toContain('Sidebar Content')
28+
})
29+
30+
it('sets active=false when content is shorter than viewport', async () => {
31+
wrapper = await mountSidebar(100)
32+
33+
expect(wrapper.attributes('data-active')).toBe('false')
34+
})
35+
36+
it('sets active=true when content is taller than viewport', async () => {
37+
wrapper = await mountSidebar(VIEWPORT_HEIGHT + 500)
38+
await nextTick()
39+
40+
expect(wrapper.attributes('data-active')).toBe('true')
41+
})
42+
43+
it('renders with direction=up by default', async () => {
44+
wrapper = await mountSidebar()
45+
46+
expect(wrapper.attributes('data-direction')).toBe('up')
47+
})
48+
})

0 commit comments

Comments
 (0)