@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 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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energysage/es-ds-components",
3
- "version": "5.0.2",
3
+ "version": "5.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "An EnergySage Vue component library",