@energysage/es-ds-components 5.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/app/components/es-sticky-bar.vue +172 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.1.0](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.0.2...es-ds-components-v5.1.0) (2026-03-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* added EsStickyBar ([#1718](https://github.com/EnergySage/es-ds/issues/1718)) ([9940605](https://github.com/EnergySage/es-ds/commit/994060525ea5c8a04ff3abfdd593e01b5c304176))
|
|
9
|
+
|
|
3
10
|
## [5.0.2](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.0.1...es-ds-components-v5.0.2) (2026-03-18)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// minimum scroll distance in a single direction before showing/hiding
|
|
3
|
+
const SCROLL_THRESHOLD = 20;
|
|
4
|
+
|
|
5
|
+
// bar states:
|
|
6
|
+
// - static: initial SSR state; bar is in normal document flow
|
|
7
|
+
// - absolute: after mount; bar is out of flow but scrolls away naturally with the page
|
|
8
|
+
// - fixed-visible: bar is pinned to the top of the viewport, visible
|
|
9
|
+
// - fixed-hidden: bar is pinned but translated off screen above the viewport
|
|
10
|
+
type BarState = 'static' | 'absolute' | 'fixed-visible' | 'fixed-hidden';
|
|
11
|
+
const barState = ref<BarState>('static');
|
|
12
|
+
|
|
13
|
+
// ref to the bar element, used to read its rendered height
|
|
14
|
+
const bar = ref<HTMLElement | null>(null);
|
|
15
|
+
// suppresses the transition for silent state swaps that shouldn't animate
|
|
16
|
+
const suppressTransition = ref(false);
|
|
17
|
+
// updated on mount and whenever the bar resizes
|
|
18
|
+
const barHeight = ref(0);
|
|
19
|
+
|
|
20
|
+
// scroll state — all plain vars since they don't need to be reactive
|
|
21
|
+
let lastScrollY = 0;
|
|
22
|
+
let scrollAnchor = 0; // position where the current scroll direction started
|
|
23
|
+
let scrollDirection = 0; // positive = down, negative = up
|
|
24
|
+
let ticking = false; // tracks whether a rAF is already queued
|
|
25
|
+
|
|
26
|
+
// switches to fixed-hidden without triggering the CSS transition — used when the bar is already
|
|
27
|
+
// off screen and we need to silently reposition it before the user scrolls back up
|
|
28
|
+
function hideWithoutAnimation() {
|
|
29
|
+
suppressTransition.value = true;
|
|
30
|
+
barState.value = 'fixed-hidden';
|
|
31
|
+
// re-enable transition after the next paint so future show/hide animations still work
|
|
32
|
+
requestAnimationFrame(() => {
|
|
33
|
+
suppressTransition.value = false;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function updateVisibility() {
|
|
38
|
+
// clamp to the valid scroll range to prevent Safari's rubber-band momentum scrolling
|
|
39
|
+
// from triggering false direction changes at the top and bottom of the page
|
|
40
|
+
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
|
41
|
+
const currentScrollY = Math.min(Math.max(window.scrollY, 0), maxScrollY);
|
|
42
|
+
const delta = currentScrollY - lastScrollY;
|
|
43
|
+
|
|
44
|
+
if (delta === 0) {
|
|
45
|
+
ticking = false;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// reset the anchor whenever the scroll direction reverses
|
|
50
|
+
const newDirection = delta > 0 ? 1 : -1;
|
|
51
|
+
if (newDirection !== scrollDirection) {
|
|
52
|
+
scrollDirection = newDirection;
|
|
53
|
+
scrollAnchor = lastScrollY;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (currentScrollY <= 0) {
|
|
57
|
+
// back at the very top: switch to absolute so bar scrolls away naturally on next scroll down
|
|
58
|
+
barState.value = 'absolute';
|
|
59
|
+
} else if (barState.value === 'absolute' && currentScrollY >= barHeight.value) {
|
|
60
|
+
// bar has scrolled fully off screen: reposition to fixed-hidden while invisible
|
|
61
|
+
hideWithoutAnimation();
|
|
62
|
+
} else if (
|
|
63
|
+
barState.value === 'fixed-hidden' &&
|
|
64
|
+
scrollDirection < 0 &&
|
|
65
|
+
scrollAnchor - currentScrollY >= SCROLL_THRESHOLD
|
|
66
|
+
) {
|
|
67
|
+
// scrolling up far enough: slide bar into view
|
|
68
|
+
barState.value = 'fixed-visible';
|
|
69
|
+
} else if (
|
|
70
|
+
barState.value === 'fixed-visible' &&
|
|
71
|
+
scrollDirection > 0 &&
|
|
72
|
+
currentScrollY - scrollAnchor >= SCROLL_THRESHOLD
|
|
73
|
+
) {
|
|
74
|
+
// scrolling down far enough: slide bar off screen
|
|
75
|
+
barState.value = 'fixed-hidden';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lastScrollY = currentScrollY;
|
|
79
|
+
ticking = false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onScroll() {
|
|
83
|
+
// batch rapid scroll events to one update per animation frame
|
|
84
|
+
if (!ticking) {
|
|
85
|
+
requestAnimationFrame(updateVisibility);
|
|
86
|
+
ticking = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onMounted(() => {
|
|
91
|
+
// measure height before switching position so the placeholder matches exactly
|
|
92
|
+
barHeight.value = bar.value?.offsetHeight ?? 0;
|
|
93
|
+
|
|
94
|
+
// switch from static to absolute — placeholder appears simultaneously to prevent layout shift
|
|
95
|
+
barState.value = 'absolute';
|
|
96
|
+
lastScrollY = window.scrollY;
|
|
97
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
98
|
+
|
|
99
|
+
// keep barHeight in sync as the bar's height changes across breakpoints
|
|
100
|
+
if (bar.value) {
|
|
101
|
+
const resizeObserver = new ResizeObserver(([entry]) => {
|
|
102
|
+
barHeight.value = entry?.contentRect.height || 0;
|
|
103
|
+
});
|
|
104
|
+
resizeObserver.observe(bar.value);
|
|
105
|
+
onUnmounted(() => resizeObserver.disconnect());
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
onUnmounted(() => {
|
|
110
|
+
window.removeEventListener('scroll', onScroll);
|
|
111
|
+
});
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<div
|
|
116
|
+
ref="bar"
|
|
117
|
+
class="es-sticky-bar bg-white"
|
|
118
|
+
:class="[`es-sticky-bar--${barState}`, { 'es-sticky-bar--no-transition': suppressTransition }]">
|
|
119
|
+
<slot />
|
|
120
|
+
</div>
|
|
121
|
+
<!-- holds the bar's space in normal flow when the bar is absolutely or fixed positioned -->
|
|
122
|
+
<div
|
|
123
|
+
v-if="barState !== 'static'"
|
|
124
|
+
:style="{ height: `${barHeight}px` }"
|
|
125
|
+
aria-hidden="true" />
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<style lang="scss" scoped>
|
|
129
|
+
@use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
|
|
130
|
+
|
|
131
|
+
$shadow: 0 0 6px 0 rgba(34, 38, 51, 0.2);
|
|
132
|
+
|
|
133
|
+
.es-sticky-bar {
|
|
134
|
+
box-shadow: $shadow;
|
|
135
|
+
left: 0;
|
|
136
|
+
right: 0;
|
|
137
|
+
top: 0;
|
|
138
|
+
z-index: 1000;
|
|
139
|
+
|
|
140
|
+
@include breakpoints.media-breakpoint-up(lg) {
|
|
141
|
+
box-shadow: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@media not (prefers-reduced-motion) {
|
|
145
|
+
transition: box-shadow 0.2s ease-in-out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
&--absolute {
|
|
149
|
+
position: absolute;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
&--fixed-visible,
|
|
153
|
+
&--fixed-hidden {
|
|
154
|
+
box-shadow: $shadow;
|
|
155
|
+
position: fixed;
|
|
156
|
+
|
|
157
|
+
@media not (prefers-reduced-motion) {
|
|
158
|
+
transition: transform 0.2s ease-in-out;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// translated off screen above the viewport
|
|
163
|
+
&--fixed-hidden {
|
|
164
|
+
transform: translateY(-100%);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// suppresses animation for silent state swaps (e.g. absolute → fixed-hidden when off screen)
|
|
168
|
+
&--no-transition {
|
|
169
|
+
transition: none !important;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
</style>
|