|
1 | | -<script setup lang="ts"> |
2 | | -const isMounted = shallowRef(false) |
3 | | -const isVisible = shallowRef(false) |
4 | | -const isScrollable = shallowRef(true) |
5 | | -const lastScrollY = shallowRef(0) |
6 | | -const footerRef = useTemplateRef('footerRef') |
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 = useSupported(() => { |
11 | | - return isMounted.value && CSS.supports('container-type', 'scroll-state') |
12 | | -}) |
13 | | -
|
14 | | -function checkScrollable() { |
15 | | - return document.documentElement.scrollHeight > window.innerHeight |
16 | | -} |
17 | | -
|
18 | | -function onScroll() { |
19 | | - // Skip JS-based visibility logic if CSS scroll-state queries handle it |
20 | | - if (supportsScrollStateQueries.value) return |
21 | | -
|
22 | | - const currentY = window.scrollY |
23 | | - const diff = lastScrollY.value - currentY |
24 | | - const nearBottom = currentY + window.innerHeight >= document.documentElement.scrollHeight - 50 |
25 | | -
|
26 | | - // Scrolling UP or near bottom -> show |
27 | | - if (Math.abs(diff) > 10) { |
28 | | - isVisible.value = diff > 0 || nearBottom |
29 | | - lastScrollY.value = currentY |
30 | | - } |
31 | | -
|
32 | | - // At top -> hide |
33 | | - if (currentY < 100) { |
34 | | - isVisible.value = false |
35 | | - } |
36 | | -
|
37 | | - // Near bottom -> always show |
38 | | - if (nearBottom) { |
39 | | - isVisible.value = true |
40 | | - } |
41 | | -} |
42 | | -
|
43 | | -function updateFooterPadding() { |
44 | | - const height = isScrollable.value && footerRef.value ? footerRef.value.offsetHeight : 0 |
45 | | - document.documentElement.style.setProperty('--footer-height', `${height}px`) |
46 | | -} |
47 | | -
|
48 | | -function onResize() { |
49 | | - isScrollable.value = checkScrollable() |
50 | | - updateFooterPadding() |
51 | | -} |
52 | | -
|
53 | | -useEventListener('scroll', onScroll, { passive: true }) |
54 | | -useEventListener('resize', onResize, { passive: true }) |
55 | | -
|
56 | | -onMounted(() => { |
57 | | - nextTick(() => { |
58 | | - lastScrollY.value = window.scrollY |
59 | | - isScrollable.value = checkScrollable() |
60 | | - updateFooterPadding() |
61 | | - // Only apply dynamic classes after mount to avoid hydration mismatch |
62 | | - isMounted.value = true |
63 | | - }) |
64 | | -}) |
65 | | -</script> |
66 | | - |
67 | 1 | <template> |
68 | | - <footer |
69 | | - ref="footerRef" |
70 | | - aria-label="Site footer" |
71 | | - class="border-t border-border bg-bg/90 backdrop-blur-md" |
72 | | - :class="[ |
73 | | - // When CSS scroll-state queries are supported, use CSS-only approach |
74 | | - supportsScrollStateQueries |
75 | | - ? 'footer-scroll-state' |
76 | | - : // JS-controlled: fixed position, hidden by default, transition only after mount |
77 | | - isScrollable |
78 | | - ? [ |
79 | | - 'fixed bottom-0 left-0 right-0 z-40', |
80 | | - isMounted && 'transition-transform duration-300 ease-out', |
81 | | - isVisible ? 'translate-y-0' : 'translate-y-full', |
82 | | - ] |
83 | | - : 'mt-auto', |
84 | | - ]" |
85 | | - > |
86 | | - <div class="container py-2 sm:py-6 flex flex-col gap-1 sm:gap-3 text-fg-subtle text-sm"> |
87 | | - <div class="flex flex-row items-center justify-between gap-2 sm:gap-4"> |
| 2 | + <footer class="border-t border-border mt-auto" aria-label="Site footer"> |
| 3 | + <div class="container py-3 sm:py-8 flex flex-col gap-2 sm:gap-4 text-fg-subtle text-sm"> |
| 4 | + <div class="flex flex-col sm:flex-row items-center justify-between gap-2 sm:gap-4"> |
88 | 5 | <p class="font-mono m-0 hidden sm:block">{{ $t('tagline') }}</p> |
89 | | - <!-- On mobile, show disclaimer here instead of tagline --> |
90 | | - <p class="text-xs text-fg-muted m-0 sm:hidden">{{ $t('non_affiliation_disclaimer') }}</p> |
91 | | - <div class="flex items-center gap-4 sm:gap-6"> |
| 6 | + <div class="flex items-center gap-3 sm:gap-6"> |
| 7 | + <NuxtLink |
| 8 | + to="/about" |
| 9 | + class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center" |
| 10 | + > |
| 11 | + {{ $t('footer.about') }} |
| 12 | + </NuxtLink> |
92 | 13 | <a |
93 | 14 | href="https://docs.npmx.dev" |
94 | 15 | target="_blank" |
95 | 16 | rel="noopener noreferrer" |
96 | | - class="link-subtle font-mono text-xs min-h-11 min-w- flex items-center" |
| 17 | + class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" |
97 | 18 | > |
98 | 19 | {{ $t('footer.docs') }} |
| 20 | + <span class="i-carbon-launch w-3 h-3" aria-hidden="true" /> |
99 | 21 | </a> |
100 | 22 | <a |
101 | 23 | href="https://repo.npmx.dev" |
102 | 24 | target="_blank" |
103 | 25 | rel="noopener noreferrer" |
104 | | - class="link-subtle font-mono text-xs min-h-11 min-w- flex items-center" |
| 26 | + class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" |
105 | 27 | > |
106 | 28 | {{ $t('footer.source') }} |
| 29 | + <span class="i-carbon-launch w-3 h-3" aria-hidden="true" /> |
107 | 30 | </a> |
108 | 31 | <a |
109 | 32 | href="https://social.npmx.dev" |
110 | 33 | target="_blank" |
111 | 34 | rel="noopener noreferrer" |
112 | | - class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center" |
| 35 | + class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" |
113 | 36 | > |
114 | 37 | {{ $t('footer.social') }} |
| 38 | + <span class="i-carbon-launch w-3 h-3" aria-hidden="true" /> |
115 | 39 | </a> |
116 | 40 | <a |
117 | 41 | href="https://chat.npmx.dev" |
118 | 42 | target="_blank" |
119 | 43 | rel="noopener noreferrer" |
120 | | - class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center" |
| 44 | + class="link-subtle font-mono text-xs min-h-8 sm:min-h-11 flex items-center gap-1" |
121 | 45 | > |
122 | 46 | {{ $t('footer.chat') }} |
| 47 | + <span class="i-carbon-launch w-3 h-3" aria-hidden="true" /> |
123 | 48 | </a> |
124 | 49 | </div> |
125 | 50 | </div> |
126 | | - <p class="text-xs text-fg-muted text-center sm:text-left m-0 hidden sm:block"> |
127 | | - {{ $t('trademark_disclaimer') }} |
| 51 | + <p class="text-xs text-fg-muted text-center sm:text-left m-0"> |
| 52 | + <span class="sm:hidden">{{ $t('non_affiliation_disclaimer') }}</span> |
| 53 | + <span class="hidden sm:inline">{{ $t('trademark_disclaimer') }}</span> |
128 | 54 | </p> |
129 | 55 | </div> |
130 | 56 | </footer> |
131 | 57 | </template> |
132 | | - |
133 | | -<style scoped> |
134 | | -/* |
135 | | - * CSS scroll-state container queries (Chrome 133+) |
136 | | - * @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors |
137 | | - * |
138 | | - * This provides a pure CSS solution for showing/hiding the footer based on scroll state. |
139 | | - * The JS fallback handles browsers without support. |
140 | | - * Once scroll-state queries become baseline, we can remove the JS scroll handling entirely. |
141 | | - */ |
142 | | -@supports (container-type: scroll-state) { |
143 | | - .footer-scroll-state { |
144 | | - position: fixed; |
145 | | - bottom: 0; |
146 | | - left: 0; |
147 | | - right: 0; |
148 | | - z-index: 40; |
149 | | - /* Hidden by default (translated off-screen) */ |
150 | | - transform: translateY(100%); |
151 | | - } |
152 | | -
|
153 | | - @media (prefers-reduced-motion: no-preference) { |
154 | | - .footer-scroll-state { |
155 | | - transition: transform 0.3s ease-out; |
156 | | - } |
157 | | - } |
158 | | -
|
159 | | - /* Show footer when user can scroll up (meaning they've scrolled down) */ |
160 | | - @container scroll-state(scrollable: top) { |
161 | | - .footer-scroll-state { |
162 | | - transform: translateY(0); |
163 | | - } |
164 | | - } |
165 | | -} |
166 | | -</style> |
0 commit comments