Skip to content

Commit 0513704

Browse files
Flo0806iiio2
andauthored
feat: make footer accessible on mobile + add scroll-to-top button (#69)
Co-authored-by: "Mr. Robot" <gomexxxx71@gmail.com>
1 parent c2ee9de commit 0513704

5 files changed

Lines changed: 260 additions & 22 deletions

File tree

app/app.vue

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,17 @@ if (import.meta.client) {
5151

5252
<AppHeader :show-logo="!isHomepage" />
5353

54-
<div id="main-content" class="flex-1">
54+
<div id="main-content" class="flex-1 flex flex-col">
5555
<NuxtPage />
5656
</div>
5757

5858
<AppFooter />
59+
60+
<ScrollToTop />
5961
</div>
6062
</template>
6163

62-
<style>
64+
<style lang="postcss">
6365
/* Base reset and defaults */
6466
*,
6567
*::before,
@@ -73,11 +75,23 @@ html {
7375
text-rendering: optimizeLegibility;
7476
}
7577
78+
/*
79+
* Enable CSS scroll-state container queries for the document
80+
* This allows the footer to query the scroll state using pure CSS
81+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
82+
*/
83+
@supports (container-type: scroll-state) {
84+
html {
85+
container-type: scroll-state;
86+
}
87+
}
88+
7689
body {
7790
margin: 0;
7891
background-color: #0a0a0a;
7992
color: #fafafa;
8093
line-height: 1.6;
94+
padding-bottom: var(--footer-height, 0);
8195
}
8296
8397
/* Default link styling for accessibility on dark background */

app/components/AppFooter.vue

Lines changed: 136 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,153 @@
1+
<script setup lang="ts">
2+
const isMounted = ref(false)
3+
const isVisible = ref(false)
4+
const isScrollable = ref(true)
5+
const lastScrollY = ref(0)
6+
const footerRef = ref<HTMLElement>()
7+
8+
// Check if CSS scroll-state container queries are supported
9+
// Once this becomes baseline, we can remove the JS scroll handling entirely
10+
const supportsScrollStateQueries = ref(false)
11+
12+
function checkScrollable() {
13+
return document.documentElement.scrollHeight > window.innerHeight
14+
}
15+
16+
function onScroll() {
17+
// Skip JS-based visibility logic if CSS scroll-state queries handle it
18+
if (supportsScrollStateQueries.value) return
19+
20+
const currentY = window.scrollY
21+
const diff = lastScrollY.value - currentY
22+
const nearBottom = currentY + window.innerHeight >= document.documentElement.scrollHeight - 50
23+
24+
// Scrolling UP or near bottom -> show
25+
if (Math.abs(diff) > 10) {
26+
isVisible.value = diff > 0 || nearBottom
27+
lastScrollY.value = currentY
28+
}
29+
30+
// At top -> hide
31+
if (currentY < 100) {
32+
isVisible.value = false
33+
}
34+
35+
// Near bottom -> always show
36+
if (nearBottom) {
37+
isVisible.value = true
38+
}
39+
}
40+
41+
function updateFooterPadding() {
42+
const height = isScrollable.value && footerRef.value ? footerRef.value.offsetHeight : 0
43+
document.documentElement.style.setProperty('--footer-height', `${height}px`)
44+
}
45+
46+
function onResize() {
47+
isScrollable.value = checkScrollable()
48+
updateFooterPadding()
49+
}
50+
51+
onMounted(() => {
52+
// Feature detect CSS scroll-state container queries (Chrome 133+)
53+
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
54+
supportsScrollStateQueries.value = CSS.supports('container-type', 'scroll-state')
55+
56+
nextTick(() => {
57+
lastScrollY.value = window.scrollY
58+
isScrollable.value = checkScrollable()
59+
updateFooterPadding()
60+
// Only apply dynamic classes after mount to avoid hydration mismatch
61+
isMounted.value = true
62+
})
63+
64+
window.addEventListener('scroll', onScroll, { passive: true })
65+
window.addEventListener('resize', onResize, { passive: true })
66+
})
67+
68+
onUnmounted(() => {
69+
window.removeEventListener('scroll', onScroll)
70+
window.removeEventListener('resize', onResize)
71+
})
72+
</script>
73+
174
<template>
2-
<footer class="border-t border-border mt-auto">
3-
<div class="container py-8 flex flex-col gap-4 text-fg-subtle text-sm">
4-
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
5-
<p class="font-mono m-0">a better browser for the npm registry</p>
6-
<div class="flex items-center gap-6">
75+
<footer
76+
ref="footerRef"
77+
aria-label="Site footer"
78+
class="border-t border-border bg-bg/90 backdrop-blur-md"
79+
:class="[
80+
// Only apply dynamic positioning classes after mount to avoid hydration mismatch
81+
!isMounted
82+
? 'mt-auto'
83+
: // When CSS scroll-state queries are supported, use CSS-only approach
84+
supportsScrollStateQueries
85+
? 'footer-scroll-state'
86+
: // Fallback to JS-controlled classes
87+
isScrollable
88+
? [
89+
'fixed bottom-0 left-0 right-0 z-40 transition-transform duration-300 ease-out',
90+
isVisible ? 'translate-y-0' : 'translate-y-full',
91+
]
92+
: 'mt-auto',
93+
]"
94+
>
95+
<div class="container py-2 sm:py-6 flex flex-col gap-1 sm:gap-3 text-fg-subtle text-sm">
96+
<div class="flex flex-row items-center justify-between gap-2 sm:gap-4">
97+
<p class="font-mono m-0 hidden sm:block">a better browser for the npm registry</p>
98+
<!-- On mobile, show disclaimer here instead of tagline -->
99+
<p class="text-xs text-fg-muted m-0 sm:hidden">not affiliated with npm, Inc.</p>
100+
<div class="flex items-center gap-4 sm:gap-6">
7101
<a
8102
href="https://github.com/npmx-dev/npmx.dev"
9103
rel="noopener noreferrer"
10-
class="link-subtle font-mono text-xs"
104+
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
11105
>
12106
source
13107
</a>
14108
<span class="text-border">|</span>
15-
<a href="https://roe.dev" rel="noopener noreferrer" class="link-subtle font-mono text-xs">
109+
<a
110+
href="https://roe.dev"
111+
rel="noopener noreferrer"
112+
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
113+
>
16114
@danielroe
17115
</a>
18116
</div>
19117
</div>
20-
<p class="text-xs text-fg-muted text-center sm:text-left m-0">
118+
<p class="text-xs text-fg-muted text-center sm:text-left m-0 hidden sm:block">
21119
npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.
22120
</p>
23121
</div>
24122
</footer>
25123
</template>
124+
125+
<style scoped>
126+
/*
127+
* CSS scroll-state container queries (Chrome 133+)
128+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
129+
*
130+
* This provides a pure CSS solution for showing/hiding the footer based on scroll state.
131+
* The JS fallback handles browsers without support.
132+
* Once scroll-state queries become baseline, we can remove the JS scroll handling entirely.
133+
*/
134+
@supports (container-type: scroll-state) {
135+
.footer-scroll-state {
136+
position: fixed;
137+
bottom: 0;
138+
left: 0;
139+
right: 0;
140+
z-index: 40;
141+
/* Hidden by default (translated off-screen) */
142+
transform: translateY(100%);
143+
transition: transform 0.3s ease-out;
144+
}
145+
146+
/* Show footer when user can scroll up (meaning they've scrolled down) */
147+
@container scroll-state(scrollable: top) {
148+
.footer-scroll-state {
149+
transform: translateY(0);
150+
}
151+
}
152+
}
153+
</style>

app/components/ScrollToTop.vue

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<script setup lang="ts">
2+
const route = useRoute()
3+
4+
// Pages where scroll-to-top should NOT be shown
5+
const excludedRoutes = new Set(['index', 'code'])
6+
7+
const isActive = computed(() => !excludedRoutes.has(route.name as string))
8+
9+
const isMounted = ref(false)
10+
const isVisible = ref(false)
11+
const scrollThreshold = 300
12+
const supportsScrollStateQueries = ref(false)
13+
14+
function onScroll() {
15+
isVisible.value = window.scrollY > scrollThreshold
16+
}
17+
18+
function scrollToTop() {
19+
window.scrollTo({ top: 0, behavior: 'smooth' })
20+
}
21+
22+
onMounted(() => {
23+
// Feature detect CSS scroll-state container queries (Chrome 133+)
24+
supportsScrollStateQueries.value = CSS.supports('container-type', 'scroll-state')
25+
26+
if (!supportsScrollStateQueries.value) {
27+
window.addEventListener('scroll', onScroll, { passive: true })
28+
onScroll()
29+
}
30+
31+
isMounted.value = true
32+
})
33+
34+
onUnmounted(() => {
35+
window.removeEventListener('scroll', onScroll)
36+
})
37+
</script>
38+
39+
<template>
40+
<!-- When CSS scroll-state is supported, use CSS-only visibility -->
41+
<button
42+
v-if="isActive && supportsScrollStateQueries"
43+
type="button"
44+
class="scroll-to-top-css fixed bottom-4 right-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
45+
aria-label="Scroll to top"
46+
@click="scrollToTop"
47+
>
48+
<span class="i-carbon-arrow-up w-5 h-5" aria-hidden="true" />
49+
</button>
50+
51+
<!-- JS fallback for browsers without scroll-state support -->
52+
<Transition
53+
v-else
54+
enter-active-class="transition-all duration-200"
55+
enter-from-class="opacity-0 translate-y-2"
56+
enter-to-class="opacity-100 translate-y-0"
57+
leave-active-class="transition-all duration-200"
58+
leave-from-class="opacity-100 translate-y-0"
59+
leave-to-class="opacity-0 translate-y-2"
60+
>
61+
<button
62+
v-if="isActive && isMounted && isVisible"
63+
type="button"
64+
class="fixed bottom-4 right-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
65+
aria-label="Scroll to top"
66+
@click="scrollToTop"
67+
>
68+
<span class="i-carbon-arrow-up w-5 h-5" aria-hidden="true" />
69+
</button>
70+
</Transition>
71+
</template>
72+
73+
<style scoped>
74+
/*
75+
* CSS scroll-state container queries (Chrome 133+)
76+
* Hide button by default, show when page can be scrolled up (user has scrolled down)
77+
*/
78+
@supports (container-type: scroll-state) {
79+
.scroll-to-top-css {
80+
opacity: 0;
81+
transform: translateY(0.5rem);
82+
pointer-events: none;
83+
transition:
84+
opacity 0.2s ease,
85+
transform 0.2s ease;
86+
}
87+
88+
@container scroll-state(scrollable: top) {
89+
.scroll-to-top-css {
90+
opacity: 1;
91+
transform: translateY(0);
92+
pointer-events: auto;
93+
}
94+
}
95+
}
96+
</style>

app/pages/code/[...path].vue

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,9 @@ useSeoMeta({
296296
</script>
297297

298298
<template>
299-
<main class="min-h-screen flex flex-col">
299+
<main class="flex-1 flex flex-col">
300300
<!-- Header -->
301-
<header class="border-b border-border bg-bg sticky top-0 z-10">
301+
<header class="border-b border-border bg-bg sticky top-14 z-20">
302302
<div class="container py-4">
303303
<!-- Package info and navigation -->
304304
<div class="flex items-center gap-2 mb-3 flex-wrap min-w-0">
@@ -384,10 +384,10 @@ useSeoMeta({
384384
</div>
385385

386386
<!-- Main content: file tree + file viewer -->
387-
<div v-else-if="fileTree" class="flex-1 flex min-h-0">
388-
<!-- File tree sidebar -->
387+
<div v-else-if="fileTree" class="flex flex-1">
388+
<!-- File tree sidebar - sticky with internal scroll -->
389389
<aside
390-
class="w-64 lg:w-72 border-r border-border overflow-y-auto shrink-0 hidden md:block bg-bg-subtle"
390+
class="w-64 lg:w-72 border-r border-border shrink-0 hidden md:block bg-bg-subtle sticky top-28 self-start h-[calc(100vh-7rem)] overflow-y-auto"
391391
>
392392
<CodeFileTree
393393
:tree="fileTree.tree"
@@ -396,8 +396,10 @@ useSeoMeta({
396396
/>
397397
</aside>
398398

399-
<!-- File content / Directory listing -->
400-
<div class="flex-1 overflow-auto min-w-0">
399+
<!-- File content / Directory listing - sticky with internal scroll on desktop -->
400+
<div
401+
class="flex-1 min-w-0 md:sticky md:top-28 md:self-start md:h-[calc(100vh-7rem)] md:overflow-y-auto"
402+
>
401403
<!-- File viewer -->
402404
<template v-if="isViewingFile && fileContent">
403405
<div

app/pages/index.vue

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,9 @@ defineOgImageComponent('Default')
2020
</script>
2121

2222
<template>
23-
<main class="container">
24-
<!-- Hero section with dramatic vertical centering -->
25-
<header
26-
class="min-h-[calc(100vh-12rem)] flex flex-col items-center justify-center text-center py-20"
27-
>
23+
<main class="container min-h-screen flex flex-col">
24+
<!-- Hero section with vertical centering -->
25+
<header class="flex-1 flex flex-col items-center justify-center text-center py-20">
2826
<!-- Animated title -->
2927
<h1
3028
class="font-mono text-5xl sm:text-7xl md:text-8xl font-medium tracking-tight mb-4 animate-fade-in animate-fill-both"

0 commit comments

Comments
 (0)