@energysage/es-ds-components 5.4.3 → 5.5.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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.5.0](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.4.4...es-ds-components-v5.5.0) (2026-05-18)
4
+
5
+
6
+ ### Features
7
+
8
+ * add EsMenuBar, EsSkipToContentLink, EsColorBand ([#1746](https://github.com/EnergySage/es-ds/issues/1746)) ([3654c21](https://github.com/EnergySage/es-ds/commit/3654c218bac408b2d93568a788d285b323e460db))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * fresh es-ds-docs package-lock to maybe solve vite-plugin-checker issue ([#1743](https://github.com/EnergySage/es-ds/issues/1743)) ([0f3fed4](https://github.com/EnergySage/es-ds/commit/0f3fed490880a61cfe5c9ce8920e5ec522720a3c))
14
+
15
+ ## [5.4.4](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.4.3...es-ds-components-v5.4.4) (2026-04-28)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * another release fix ([#1741](https://github.com/EnergySage/es-ds/issues/1741)) ([d7401f6](https://github.com/EnergySage/es-ds/commit/d7401f6749d98a47cddaa2e33b121941126c56fb))
21
+
22
+
23
+ ### Dependencies
24
+
25
+ * The following workspace dependencies were updated
26
+ * devDependencies
27
+ * @energysage/es-ds-styles bumped from ^3.5.3 to ^3.5.4
28
+
3
29
  ## [5.4.3](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.4.2...es-ds-components-v5.4.3) (2026-04-28)
4
30
 
5
31
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # es-ds-components
2
2
 
3
- This is an EnergySage Nuxt layer with components for our design system.
3
+ An EnergySage Nuxt layer with components for our design system
4
4
 
5
5
  ## Usage
6
6
 
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ interface IProps {
3
+ height?: number;
4
+ }
5
+ const props = withDefaults(defineProps<IProps>(), {
6
+ height: 4,
7
+ });
8
+
9
+ const heightPx = computed(() => `${props.height}px`);
10
+ </script>
11
+
12
+ <template>
13
+ <div class="d-flex w-100">
14
+ <div class="es-color-band-item primary bg-dark-blue flex-grow-1"></div>
15
+ <div class="es-color-band-item primary bg-warm-orange flex-grow-1"></div>
16
+ <div class="es-color-band-item bg-blue-300 flex-grow-1"></div>
17
+ <div class="es-color-band-item bg-blue-100 flex-grow-1"></div>
18
+ <div class="es-color-band-item bg-success-900 flex-grow-1"></div>
19
+ <div class="es-color-band-item bg-warning-900 flex-grow-1"></div>
20
+ </div>
21
+ </template>
22
+
23
+ <style lang="scss" scoped>
24
+ .es-color-band-item {
25
+ height: v-bind(heightPx);
26
+ width: 12.5%;
27
+
28
+ &.primary {
29
+ width: 25%;
30
+ }
31
+ }
32
+ </style>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { NavigationMenuList } from 'reka-ui';
3
+
4
+ interface IProps {
5
+ heading?: string;
6
+ }
7
+ withDefaults(defineProps<IProps>(), {
8
+ heading: '',
9
+ });
10
+ </script>
11
+
12
+ <template>
13
+ <es-col
14
+ md="6"
15
+ lg="4"
16
+ xl="3">
17
+ <h2
18
+ v-if="heading"
19
+ class="eyebrow">
20
+ {{ heading }}
21
+ </h2>
22
+ <navigation-menu-list class="list-unstyled">
23
+ <slot />
24
+ </navigation-menu-list>
25
+ </es-col>
26
+ </template>
@@ -0,0 +1,9 @@
1
+ <script setup lang="ts">
2
+ import { NavigationMenuItem } from 'reka-ui';
3
+ </script>
4
+
5
+ <template>
6
+ <navigation-menu-item>
7
+ <slot />
8
+ </navigation-menu-item>
9
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import { NavigationMenuItem, NavigationMenuLink } from 'reka-ui';
3
+
4
+ interface IProps {
5
+ href: string;
6
+ name: string;
7
+ target?: string;
8
+ }
9
+ withDefaults(defineProps<IProps>(), {
10
+ target: '_self',
11
+ });
12
+ </script>
13
+
14
+ <template>
15
+ <navigation-menu-item>
16
+ <navigation-menu-link
17
+ as-child
18
+ @select.prevent.stop>
19
+ <a
20
+ class="d-block font-size-75 font-weight-normal text-dark-blue"
21
+ :href="href"
22
+ :target="target">
23
+ <span class="d-block py-50 rounded-sm">
24
+ {{ name }}
25
+ </span>
26
+ </a>
27
+ </navigation-menu-link>
28
+ </navigation-menu-item>
29
+ </template>
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ import { injectNavigationMenuItemContext, NavigationMenuContent, NavigationMenuList } from 'reka-ui';
3
+ import { ES_MENU_BAR_ALIGNMENT_REGISTRY_KEY, type EsMenuBarFlyoutAlign } from '../utils/menu-bar';
4
+
5
+ interface IProps {
6
+ align?: EsMenuBarFlyoutAlign;
7
+ }
8
+ const props = withDefaults(defineProps<IProps>(), {
9
+ align: 'center',
10
+ });
11
+
12
+ const fullWidth = inject('fullWidth', false);
13
+
14
+ // register this flyout's align preference with the parent EsMenuBar so the shared
15
+ // Reka viewport can switch align values as the active menu changes
16
+ const itemContext = injectNavigationMenuItemContext();
17
+ const registry = inject(ES_MENU_BAR_ALIGNMENT_REGISTRY_KEY, null);
18
+
19
+ watch(
20
+ () => props.align,
21
+ (align) => {
22
+ registry?.register(itemContext.value, align);
23
+ },
24
+ { immediate: true },
25
+ );
26
+
27
+ onBeforeUnmount(() => {
28
+ registry?.unregister(itemContext.value);
29
+ });
30
+ </script>
31
+
32
+ <template>
33
+ <!-- classes on Reka UI elements defined with :deep() in EsMenuBar due to rendering method -->
34
+ <navigation-menu-content
35
+ v-bind="$attrs"
36
+ class="es-menu-bar-flyout">
37
+ <div class="es-menu-bar-flyout-pane">
38
+ <template v-if="fullWidth">
39
+ <div class="px-md-150 px-xxl-0 py-200">
40
+ <es-row class="justify-content-end">
41
+ <slot />
42
+ </es-row>
43
+ </div>
44
+ </template>
45
+ <template v-else>
46
+ <navigation-menu-list class="list-unstyled mb-0">
47
+ <slot />
48
+ </navigation-menu-list>
49
+ </template>
50
+ </div>
51
+ <es-color-band
52
+ v-if="fullWidth"
53
+ class="es-menu-bar-color-band"
54
+ :height="8" />
55
+ </navigation-menu-content>
56
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ import { NavigationMenuItem } from 'reka-ui';
3
+ </script>
4
+
5
+ <template>
6
+ <!-- classes on Reka UI elements defined with :deep() in EsMenuBar due to rendering method -->
7
+ <navigation-menu-item
8
+ v-bind="$attrs"
9
+ class="es-menu-bar-item">
10
+ <slot />
11
+ </navigation-menu-item>
12
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { NavigationMenuLink } from 'reka-ui';
3
+
4
+ interface IProps {
5
+ href: string;
6
+ target?: string;
7
+ }
8
+ withDefaults(defineProps<IProps>(), {
9
+ target: '_self',
10
+ });
11
+ </script>
12
+
13
+ <template>
14
+ <navigation-menu-link
15
+ as-child
16
+ @select.prevent.stop>
17
+ <a
18
+ class="es-menu-bar-link align-items-center d-flex font-weight-bolder text-dark-blue"
19
+ :href="href"
20
+ :target="target">
21
+ <slot />
22
+ </a>
23
+ </navigation-menu-link>
24
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ import { NavigationMenuTrigger } from 'reka-ui';
3
+ </script>
4
+
5
+ <template>
6
+ <!-- classes on Reka UI elements defined with :deep() in EsMenuBar due to rendering method -->
7
+ <navigation-menu-trigger
8
+ v-bind="$attrs"
9
+ class="es-menu-bar-item-trigger align-items-center d-flex font-weight-bolder">
10
+ <slot />
11
+ </navigation-menu-trigger>
12
+ </template>
@@ -0,0 +1,514 @@
1
+ <script setup lang="ts">
2
+ import { useScrollLock } from '@vueuse/core';
3
+ import { NavigationMenuList, NavigationMenuRoot, NavigationMenuViewport } from 'reka-ui';
4
+ import {
5
+ ES_MENU_BAR_ALIGNMENT_REGISTRY_KEY,
6
+ ES_MENU_BAR_CLOSE_EVENT_NAME,
7
+ ES_MENU_BAR_OPEN_CLOSE_DURATION_MS,
8
+ ES_MENU_BAR_OPEN_EVENT_NAME,
9
+ type EsMenuBarFlyoutAlign,
10
+ } from '../utils/menu-bar';
11
+ import { omit } from '../utils/omit';
12
+ import { useEsdsEvents } from '../composables/events';
13
+
14
+ interface IProps {
15
+ class?: string;
16
+ fullWidth?: boolean;
17
+ height?: number;
18
+ showOverlayWhenOpen?: boolean;
19
+ }
20
+ const props = withDefaults(defineProps<IProps>(), {
21
+ class: '',
22
+ fullWidth: false,
23
+ height: 40,
24
+ showOverlayWhenOpen: true,
25
+ });
26
+
27
+ // used to ensure we don't close our menu when emitting our own menu open event
28
+ let isOpening = false;
29
+
30
+ const emitter = useEsdsEvents();
31
+ const isScrollLocked = useScrollLock(import.meta.client ? document.body : null);
32
+
33
+ const activeMenuId = ref('');
34
+
35
+ const heightPx = computed(() => `${props.height}px`);
36
+
37
+ provide('fullWidth', props.fullWidth);
38
+
39
+ // each EsMenuBarFlyout registers its align preference here, keyed by its Reka item id;
40
+ // the viewport's align is reactively driven by whichever menu is currently active
41
+ const flyoutAlignments = reactive(new Map<string, EsMenuBarFlyoutAlign>());
42
+ provide(ES_MENU_BAR_ALIGNMENT_REGISTRY_KEY, {
43
+ register: (id, align) => {
44
+ flyoutAlignments.set(id, align);
45
+ },
46
+ unregister: (id) => {
47
+ flyoutAlignments.delete(id);
48
+ },
49
+ });
50
+ const activeAlign = computed<EsMenuBarFlyoutAlign>(() => flyoutAlignments.get(activeMenuId.value) ?? 'center');
51
+
52
+ watch(activeMenuId, async (newVal: string, oldVal: string) => {
53
+ // lock page scrolling when menu is open, unless we're not showing the overlay
54
+ isScrollLocked.value = props.showOverlayWhenOpen && !!newVal;
55
+
56
+ if (!oldVal && newVal) {
57
+ // if the menu is opening, emit event so:
58
+ // - any other EsMenuBars on the page can close (only an issue during keyboard navigation between menus)
59
+ // - EsStickyBar can change from transparent to white background
60
+ isOpening = true;
61
+ emitter.emit(ES_MENU_BAR_OPEN_EVENT_NAME);
62
+ } else if (oldVal && !newVal) {
63
+ // if the menu is closing, emit event so:
64
+ // - EsStickyBar can change from white background back to transparent
65
+ emitter.emit(ES_MENU_BAR_CLOSE_EVENT_NAME);
66
+ }
67
+ });
68
+
69
+ // keep a reference to the handler so unmount can remove only this instance's listener —
70
+ // emitter.off(type) without a handler wipes EVERY listener for that event, which would
71
+ // break other components (e.g. EsStickyBar) that are also subscribed
72
+ const handleMenuOpen = () => {
73
+ if (isOpening) {
74
+ // if we're opening our own menu, ignore the event
75
+ isOpening = false;
76
+ } else {
77
+ // if another menu just opened, close ours
78
+ // (this is only an issue during keyboard navigation between menus)
79
+ activeMenuId.value = '';
80
+ }
81
+ };
82
+
83
+ onMounted(() => {
84
+ emitter.on(ES_MENU_BAR_OPEN_EVENT_NAME, handleMenuOpen);
85
+ });
86
+
87
+ onUnmounted(() => {
88
+ emitter.off(ES_MENU_BAR_OPEN_EVENT_NAME, handleMenuOpen);
89
+ });
90
+ </script>
91
+
92
+ <template>
93
+ <div
94
+ class="es-menu-bar"
95
+ :class="[props.class]"
96
+ :style="{ '--es-menu-bar-open-close-duration': `${ES_MENU_BAR_OPEN_CLOSE_DURATION_MS}ms` }">
97
+ <navigation-menu-root
98
+ v-model="activeMenuId"
99
+ v-bind="omit('class', $attrs)"
100
+ class="es-menu-bar-root my-0"
101
+ :class="{ 'position-relative': !fullWidth }"
102
+ disable-hover-trigger
103
+ disable-pointer-leave-close>
104
+ <navigation-menu-list class="es-menu-bar-list">
105
+ <slot />
106
+ </navigation-menu-list>
107
+ <navigation-menu-viewport
108
+ :align="activeAlign"
109
+ class="es-menu-bar-viewport"
110
+ :class="{
111
+ 'full-width': fullWidth,
112
+ }" />
113
+ </navigation-menu-root>
114
+ <teleport
115
+ v-if="showOverlayWhenOpen"
116
+ to="body">
117
+ <transition name="es-menu-bar-overlay">
118
+ <div
119
+ v-if="activeMenuId"
120
+ class="es-menu-bar-overlay"
121
+ :style="{
122
+ '--es-menu-bar-height': heightPx,
123
+ '--es-menu-bar-open-close-duration': `${ES_MENU_BAR_OPEN_CLOSE_DURATION_MS}ms`,
124
+ }"></div>
125
+ </transition>
126
+ </teleport>
127
+ </div>
128
+ </template>
129
+
130
+ <style lang="scss" scoped>
131
+ @use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
132
+ @use '@energysage/es-ds-styles/scss/variables';
133
+ @use 'sass:map';
134
+
135
+ $open-close-timing-function: ease-in-out;
136
+
137
+ $switch-menus-duration: 400ms;
138
+ $switch-menus-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
139
+
140
+ @keyframes enterFromLeft {
141
+ from {
142
+ opacity: 0;
143
+ transform: translateX(calc(-1 * var(--es-menu-bar-flyout-translate)));
144
+ }
145
+ to {
146
+ opacity: 1;
147
+ transform: translateX(0);
148
+ }
149
+ }
150
+
151
+ @keyframes enterFromRight {
152
+ from {
153
+ opacity: 0;
154
+ transform: translateX(var(--es-menu-bar-flyout-translate));
155
+ }
156
+ to {
157
+ opacity: 1;
158
+ transform: translateX(0);
159
+ }
160
+ }
161
+
162
+ /* no-op animation needed so Reka UI persists the exiting menu, allowing the actual exit animation to show */
163
+ @keyframes presenceExitAnimationForContent {
164
+ 0% {
165
+ border-color: #fff;
166
+ }
167
+ 50% {
168
+ border-color: #fefefe;
169
+ }
170
+ 100% {
171
+ border-color: #fff;
172
+ }
173
+ }
174
+
175
+ @keyframes exitToLeft {
176
+ from {
177
+ opacity: 1;
178
+ transform: translateX(0);
179
+ }
180
+ to {
181
+ opacity: 0;
182
+ transform: translateX(calc(-1 * var(--es-menu-bar-flyout-translate)));
183
+ }
184
+ }
185
+
186
+ @keyframes exitToRight {
187
+ from {
188
+ opacity: 1;
189
+ transform: translateX(0);
190
+ }
191
+ to {
192
+ opacity: 0;
193
+ transform: translateX(var(--es-menu-bar-flyout-translate));
194
+ }
195
+ }
196
+
197
+ @keyframes fadeIn {
198
+ from {
199
+ opacity: 0;
200
+ }
201
+ to {
202
+ opacity: 1;
203
+ }
204
+ }
205
+
206
+ @keyframes fadeOut {
207
+ from {
208
+ opacity: 1;
209
+ }
210
+ to {
211
+ opacity: 0;
212
+ }
213
+ }
214
+
215
+ @keyframes menuOpen {
216
+ from {
217
+ height: 0;
218
+ }
219
+ to {
220
+ height: var(--reka-navigation-menu-viewport-height);
221
+ }
222
+ }
223
+
224
+ @keyframes menuClose {
225
+ from {
226
+ height: var(--reka-navigation-menu-viewport-height);
227
+ }
228
+ to {
229
+ height: 0;
230
+ }
231
+ }
232
+
233
+ @keyframes menuOpenWithShadow {
234
+ from {
235
+ box-shadow: 0 12px 12px 0 rgba(0, 0, 0, 0);
236
+ height: 0;
237
+ }
238
+ to {
239
+ box-shadow: 0 12px 12px 0 rgba(0, 0, 0, 0.25);
240
+ height: var(--reka-navigation-menu-viewport-height);
241
+ }
242
+ }
243
+
244
+ @keyframes menuCloseWithShadow {
245
+ from {
246
+ box-shadow: 0 12px 12px 0 rgba(0, 0, 0, 0.25);
247
+ height: var(--reka-navigation-menu-viewport-height);
248
+ }
249
+ to {
250
+ box-shadow: 0 12px 12px 0 rgba(0, 0, 0, 0);
251
+ height: 0;
252
+ }
253
+ }
254
+
255
+ .es-menu-bar {
256
+ :deep(.es-menu-bar-list) {
257
+ align-items: center;
258
+ display: flex;
259
+ gap: variables.$spacer * 0.25;
260
+ list-style: none;
261
+ margin: 0;
262
+ padding: 0;
263
+ position: relative;
264
+ z-index: 999;
265
+
266
+ @include breakpoints.media-breakpoint-up(xl) {
267
+ gap: variables.$spacer;
268
+ }
269
+
270
+ li {
271
+ margin: 0;
272
+ }
273
+ }
274
+
275
+ :deep(.es-menu-bar-link),
276
+ :deep(.es-menu-bar-item-trigger) {
277
+ color: variables.$dark-blue;
278
+ height: v-bind(heightPx);
279
+ padding: 0 variables.$spacer * 0.5;
280
+ position: relative;
281
+ white-space: nowrap;
282
+
283
+ &::after {
284
+ border-bottom: 4px solid variables.$warm-orange;
285
+ bottom: 0;
286
+ content: '';
287
+ left: 0;
288
+ opacity: 0;
289
+ position: absolute;
290
+ right: 0;
291
+
292
+ @media not (prefers-reduced-motion) {
293
+ transition: opacity var(--es-menu-bar-open-close-duration) ease-in-out;
294
+ }
295
+ }
296
+
297
+ @media (hover: hover) {
298
+ text-decoration: none;
299
+
300
+ &:hover {
301
+ &::after {
302
+ opacity: 1;
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ :deep(.es-menu-bar-item-trigger) {
309
+ -webkit-appearance: none;
310
+ appearance: none;
311
+ background: transparent;
312
+ border: none;
313
+ margin: 0;
314
+
315
+ &[data-state='open']::after {
316
+ opacity: 1;
317
+ }
318
+ }
319
+
320
+ :deep(.es-menu-bar-viewport) {
321
+ height: var(--reka-navigation-menu-viewport-height);
322
+ overflow: hidden;
323
+ position: absolute;
324
+ z-index: 1000;
325
+
326
+ @media not (prefers-reduced-motion) {
327
+ transition: height $switch-menus-duration $switch-menus-timing-function;
328
+
329
+ &[data-state='open'] {
330
+ animation: menuOpen var(--es-menu-bar-open-close-duration) $open-close-timing-function;
331
+ }
332
+
333
+ &[data-state='closed'] {
334
+ animation: menuClose var(--es-menu-bar-open-close-duration) $open-close-timing-function;
335
+ }
336
+ }
337
+
338
+ /* menu pane background */
339
+ &::before {
340
+ background: radial-gradient(
341
+ 199.54% 139.89% at 0% 2.18%,
342
+ rgba(255, 255, 255, 0.95) 0%,
343
+ variables.$white 31.73%,
344
+ rgba(255, 255, 255, 0.9) 100%
345
+ );
346
+ bottom: 0;
347
+ content: '';
348
+ left: 0;
349
+ position: absolute;
350
+ right: 0;
351
+ top: 0;
352
+ z-index: 2;
353
+ }
354
+
355
+ &.full-width {
356
+ left: 0;
357
+ right: 0;
358
+
359
+ .es-menu-bar-flyout {
360
+ width: 100%;
361
+ /* row's content box = pane − 2·wrapper-padding + grid-gutter (row's negative margins). matches <es-col md="6" lg="4" xl="3"> in es-menu-bar-flyout-column so column text stays aligned during the cross-slide */
362
+ --es-menu-bar-flyout-row-width: calc(
363
+ min(100%, #{map.get(variables.$container-max-widths, xxl)}) - 2 *
364
+ #{map.get(variables.$spacers, 150)} + #{variables.$grid-gutter-width}
365
+ );
366
+ --es-menu-bar-flyout-translate: calc(var(--es-menu-bar-flyout-row-width) * 6 / 12);
367
+
368
+ @include breakpoints.media-breakpoint-up(lg) {
369
+ --es-menu-bar-flyout-translate: calc(var(--es-menu-bar-flyout-row-width) * 4 / 12);
370
+ }
371
+
372
+ @include breakpoints.media-breakpoint-up(xl) {
373
+ --es-menu-bar-flyout-translate: calc(var(--es-menu-bar-flyout-row-width) * 3 / 12);
374
+ }
375
+
376
+ @include breakpoints.media-breakpoint-up(xxl) {
377
+ /* wrapper padding becomes 0 and pane's half-gutter padding cancels the row's negative margin → row width = pane */
378
+ --es-menu-bar-flyout-row-width: min(100%, #{map.get(variables.$container-max-widths, xxl)});
379
+ }
380
+
381
+ /* in full width mode when switching menus, the flyout (and its sibling color band) stays in place — only the pane slides. the no-op on the flyout itself keeps Reka UI from unmounting the exiting menu before the pane's exit animation finishes */
382
+ @media not (prefers-reduced-motion) {
383
+ &[data-motion='from-start'] .es-menu-bar-flyout-pane {
384
+ animation: enterFromLeft $switch-menus-duration $switch-menus-timing-function;
385
+ }
386
+
387
+ &[data-motion='from-end'] .es-menu-bar-flyout-pane {
388
+ animation: enterFromRight $switch-menus-duration $switch-menus-timing-function;
389
+ }
390
+
391
+ &[data-motion='to-start'] {
392
+ animation: presenceExitAnimationForContent $switch-menus-duration $switch-menus-timing-function;
393
+
394
+ .es-menu-bar-flyout-pane {
395
+ animation: exitToLeft $switch-menus-duration $switch-menus-timing-function;
396
+ }
397
+ }
398
+
399
+ &[data-motion='to-end'] {
400
+ animation: presenceExitAnimationForContent $switch-menus-duration $switch-menus-timing-function;
401
+
402
+ .es-menu-bar-flyout-pane {
403
+ animation: exitToRight $switch-menus-duration $switch-menus-timing-function;
404
+ }
405
+ }
406
+ }
407
+
408
+ .es-menu-bar-flyout-pane {
409
+ margin: 0 auto;
410
+ max-width: map.get(variables.$container-max-widths, xxl);
411
+
412
+ /* at xxl breakpoint only, match es-container padding */
413
+ @include breakpoints.media-breakpoint-up(xxl) {
414
+ padding-left: variables.$grid-gutter-width * 0.5 !important;
415
+ padding-right: variables.$grid-gutter-width * 0.5 !important;
416
+ }
417
+ }
418
+ }
419
+ }
420
+
421
+ &:not(.full-width) {
422
+ border: 1px solid variables.$gray-50;
423
+ border-radius: 0 0 variables.$spacer variables.$spacer;
424
+ box-shadow: 0 12px 12px 0 rgba(0, 0, 0, 0.25);
425
+ left: var(--reka-navigation-menu-viewport-left);
426
+ width: var(--reka-navigation-menu-viewport-width);
427
+
428
+ @media not (prefers-reduced-motion) {
429
+ transition:
430
+ height $switch-menus-duration $switch-menus-timing-function,
431
+ left $switch-menus-duration $switch-menus-timing-function;
432
+
433
+ &[data-state='open'] {
434
+ animation: menuOpenWithShadow var(--es-menu-bar-open-close-duration) $open-close-timing-function;
435
+ }
436
+
437
+ &[data-state='closed'] {
438
+ animation: menuCloseWithShadow var(--es-menu-bar-open-close-duration) $open-close-timing-function;
439
+ }
440
+ }
441
+
442
+ .es-menu-bar-flyout {
443
+ padding: variables.$spacer * 1.5;
444
+ width: 300px;
445
+
446
+ @media not (prefers-reduced-motion) {
447
+ &[data-motion='from-start'],
448
+ &[data-motion='from-end'] {
449
+ .es-menu-bar-flyout-pane {
450
+ animation: fadeIn $switch-menus-duration $switch-menus-timing-function;
451
+ }
452
+ }
453
+
454
+ &[data-motion='to-start'],
455
+ &[data-motion='to-end'] {
456
+ /* no-op animation needed so Reka UI persists the exiting menu, allowing the actual exit animation to show */
457
+ animation: presenceExitAnimationForContent $switch-menus-duration $switch-menus-timing-function;
458
+
459
+ /**
460
+ * animation needs to be on the child to enable a smooth cross-fade, otherwise
461
+ * both parents are visible simultaneously next to each other
462
+ */
463
+ .es-menu-bar-flyout-pane {
464
+ animation: fadeOut $switch-menus-duration $switch-menus-timing-function;
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ :deep(.es-menu-bar-flyout) {
473
+ left: 0;
474
+ position: absolute;
475
+ top: 0;
476
+ z-index: 3;
477
+ }
478
+
479
+ /* this could be in EsMenuBarContent but needs to be here to share SASS variables */
480
+ :deep(.es-menu-bar-color-band) {
481
+ left: 0;
482
+ position: absolute;
483
+ right: 0;
484
+ top: calc(var(--reka-navigation-menu-viewport-height) - 8px);
485
+
486
+ @media not (prefers-reduced-motion) {
487
+ transition: top $switch-menus-duration $switch-menus-timing-function;
488
+ }
489
+ }
490
+ }
491
+
492
+ .es-menu-bar-overlay {
493
+ background-color: variables.$black;
494
+ height: 2 * 100vh;
495
+ left: 0;
496
+ opacity: 0.25;
497
+ position: fixed;
498
+ top: var(--es-menu-bar-height);
499
+ width: 100vw;
500
+ z-index: 999;
501
+
502
+ @media not (prefers-reduced-motion) {
503
+ &-enter-active,
504
+ &-leave-active {
505
+ transition: opacity var(--es-menu-bar-open-close-duration) $open-close-timing-function;
506
+ }
507
+
508
+ &-enter-from,
509
+ &-leave-to {
510
+ opacity: 0;
511
+ }
512
+ }
513
+ }
514
+ </style>
@@ -36,7 +36,7 @@ withDefaults(defineProps<Props>(), {
36
36
  </p>
37
37
  <p
38
38
  v-if="subtitle"
39
- class="font-size-75 mb-0">
39
+ class="font-size-75 mb-0 mt-25">
40
40
  {{ subtitle }}
41
41
  </p>
42
42
  </div>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ interface IProps {
3
+ href?: string;
4
+ }
5
+ withDefaults(defineProps<IProps>(), {
6
+ href: '#main-content',
7
+ });
8
+ </script>
9
+
10
+ <template>
11
+ <a
12
+ :href="href"
13
+ class="es-skip-to-content-link font-size-75 font-weight-semibold sr-only"
14
+ >Skip to main content</a
15
+ >
16
+ </template>
17
+
18
+ <style lang="scss" scoped>
19
+ @use '@energysage/es-ds-styles/scss/variables';
20
+
21
+ .es-skip-to-content-link:focus-visible {
22
+ /* increase contrast for the times when it appears above the logo */
23
+ background-color: variables.$white;
24
+ /* undo all the sr-only class styles */
25
+ clip: auto;
26
+ height: auto;
27
+ left: 0.5rem;
28
+ margin: 0;
29
+ position: absolute;
30
+ top: 0.5rem;
31
+ width: auto;
32
+ /* bring it above the nav so it's actually visible */
33
+ z-index: 1001;
34
+ }
35
+ </style>
@@ -1,7 +1,23 @@
1
1
  <script setup lang="ts">
2
+ import { useEsdsEvents } from '../composables/events';
3
+ import {
4
+ ES_MENU_BAR_CLOSE_EVENT_NAME,
5
+ ES_MENU_BAR_OPEN_CLOSE_DURATION_MS,
6
+ ES_MENU_BAR_OPEN_EVENT_NAME,
7
+ } from '../utils/menu-bar';
8
+
9
+ interface IProps {
10
+ transparentStartingAtBreakpoint?: 'lg' | 'xl' | 'xxl' | '';
11
+ }
12
+ withDefaults(defineProps<IProps>(), {
13
+ transparentStartingAtBreakpoint: '',
14
+ });
15
+
2
16
  // minimum scroll distance in a single direction before showing/hiding
3
17
  const SCROLL_THRESHOLD = 20;
4
18
 
19
+ const emitter = useEsdsEvents();
20
+
5
21
  // bar states:
6
22
  // - static: initial SSR state; bar is in normal document flow
7
23
  // - absolute: after mount; bar is out of flow but scrolls away naturally with the page
@@ -16,6 +32,11 @@ const bar = ref<HTMLElement | null>(null);
16
32
  const suppressTransition = ref(false);
17
33
  // updated on mount and whenever the bar resizes
18
34
  const barHeight = ref(0);
35
+ // keep track of when the mouse is over the sticky bar (to change bg to white)
36
+ const isHovered = ref(false);
37
+ // number of EsMenuBars currently open inside this sticky bar; counting (rather than
38
+ // a boolean) makes the state order-independent when one menu closes as another opens
39
+ const openMenuCount = ref(0);
19
40
 
20
41
  // scroll state — all plain vars since they don't need to be reactive
21
42
  let lastScrollY = 0;
@@ -87,6 +108,27 @@ function onScroll() {
87
108
  }
88
109
  }
89
110
 
111
+ // defined at script scope so the same references can be passed to emitter.off() —
112
+ // calling emitter.off(type) without a handler removes EVERY listener for that event,
113
+ // including ones registered by other components
114
+ const onMenuBarOpen = () => {
115
+ openMenuCount.value += 1;
116
+ };
117
+
118
+ const onMenuBarClose = () => {
119
+ const decrement = () => {
120
+ openMenuCount.value = Math.max(0, openMenuCount.value - 1);
121
+ };
122
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
123
+ // close animation is disabled — turn off active state immediately
124
+ decrement();
125
+ } else {
126
+ // delay so the menu can roll up into the bar before the bar fades to transparent
127
+ // (rather than rolling up into nothingness due to the bar already having faded out)
128
+ setTimeout(decrement, ES_MENU_BAR_OPEN_CLOSE_DURATION_MS);
129
+ }
130
+ };
131
+
90
132
  onMounted(() => {
91
133
  // measure height before switching position so the placeholder matches exactly
92
134
  barHeight.value = bar.value?.offsetHeight ?? 0;
@@ -104,19 +146,34 @@ onMounted(() => {
104
146
  resizeObserver.observe(bar.value);
105
147
  onUnmounted(() => resizeObserver.disconnect());
106
148
  }
149
+
150
+ emitter.on(ES_MENU_BAR_OPEN_EVENT_NAME, onMenuBarOpen);
151
+ emitter.on(ES_MENU_BAR_CLOSE_EVENT_NAME, onMenuBarClose);
107
152
  });
108
153
 
109
154
  onUnmounted(() => {
110
155
  window.removeEventListener('scroll', onScroll);
156
+
157
+ emitter.off(ES_MENU_BAR_OPEN_EVENT_NAME, onMenuBarOpen);
158
+ emitter.off(ES_MENU_BAR_CLOSE_EVENT_NAME, onMenuBarClose);
111
159
  });
112
160
  </script>
113
161
 
114
162
  <template>
115
163
  <div
116
164
  ref="bar"
117
- class="es-sticky-bar bg-white"
118
- :class="[`es-sticky-bar--${barState}`, { 'es-sticky-bar--no-transition': suppressTransition }]"
119
- v-bind="$attrs">
165
+ class="es-sticky-bar"
166
+ :class="[
167
+ `es-sticky-bar--${barState}`,
168
+ transparentStartingAtBreakpoint && `es-sticky-bar--transparent-from-${transparentStartingAtBreakpoint}`,
169
+ {
170
+ 'es-sticky-bar--no-transition': suppressTransition,
171
+ 'es-sticky-bar--active': isHovered || openMenuCount > 0,
172
+ },
173
+ ]"
174
+ v-bind="$attrs"
175
+ @mouseover="isHovered = true"
176
+ @mouseout="isHovered = false">
120
177
  <slot />
121
178
  </div>
122
179
  <!-- holds the bar's space in normal flow when the bar is absolutely or fixed positioned -->
@@ -128,22 +185,31 @@ onUnmounted(() => {
128
185
 
129
186
  <style lang="scss" scoped>
130
187
  @use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
188
+ @use '@energysage/es-ds-styles/scss/variables' as variables;
131
189
 
132
190
  $shadow: 0 0 6px 0 rgba(34, 38, 51, 0.2);
133
191
 
134
192
  .es-sticky-bar {
193
+ background-color: variables.$white;
135
194
  box-shadow: $shadow;
136
195
  left: 0;
137
196
  right: 0;
138
197
  top: 0;
139
198
  z-index: 1000;
140
199
 
141
- @include breakpoints.media-breakpoint-up(lg) {
142
- box-shadow: none;
200
+ @each $bp in (lg, xl, xxl) {
201
+ &--transparent-from-#{$bp} {
202
+ @include breakpoints.media-breakpoint-up($bp) {
203
+ background-color: transparent;
204
+ box-shadow: none;
205
+ }
206
+ }
143
207
  }
144
208
 
145
209
  @media not (prefers-reduced-motion) {
146
- transition: box-shadow 0.2s ease-in-out;
210
+ transition:
211
+ background 0.2s ease-in-out,
212
+ box-shadow 0.2s ease-in-out;
147
213
  }
148
214
 
149
215
  &--absolute {
@@ -152,6 +218,7 @@ $shadow: 0 0 6px 0 rgba(34, 38, 51, 0.2);
152
218
 
153
219
  &--fixed-visible,
154
220
  &--fixed-hidden {
221
+ background-color: variables.$white;
155
222
  box-shadow: $shadow;
156
223
  position: fixed;
157
224
 
@@ -170,5 +237,10 @@ $shadow: 0 0 6px 0 rgba(34, 38, 51, 0.2);
170
237
  &--no-transition {
171
238
  transition: none !important;
172
239
  }
240
+
241
+ &--active {
242
+ background-color: variables.$white;
243
+ box-shadow: $shadow;
244
+ }
173
245
  }
174
246
  </style>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { PhFan } from '@phosphor-icons/vue';
3
+
4
+ interface Props {
5
+ height?: string;
6
+ width?: string;
7
+ }
8
+ withDefaults(defineProps<Props>(), {
9
+ height: '24px',
10
+ width: '24px',
11
+ });
12
+ </script>
13
+
14
+ <template>
15
+ <ph-fan :size="width" />
16
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { PhUserCircle } from '@phosphor-icons/vue';
3
+
4
+ interface Props {
5
+ height?: string;
6
+ width?: string;
7
+ }
8
+ withDefaults(defineProps<Props>(), {
9
+ height: '24px',
10
+ width: '24px',
11
+ });
12
+ </script>
13
+
14
+ <template>
15
+ <ph-user-circle :size="width" />
16
+ </template>
@@ -0,0 +1,9 @@
1
+ import mitt from 'mitt';
2
+
3
+ // create a singleton global event bus
4
+ const emitter = mitt();
5
+
6
+ // expose the singleton via a composable
7
+ export function useEsdsEvents() {
8
+ return emitter;
9
+ }
@@ -0,0 +1,25 @@
1
+ import type { InjectionKey } from 'vue';
2
+
3
+ // these constants are used by both EsMenuBar and EsStickyBar
4
+ export const ES_MENU_BAR_CLOSE_EVENT_NAME = 'es-menu-bar-close';
5
+ export const ES_MENU_BAR_OPEN_CLOSE_DURATION_MS = 200;
6
+ export const ES_MENU_BAR_OPEN_EVENT_NAME = 'es-menu-bar-open';
7
+
8
+ export type EsMenuBarFlyoutAlign = 'start' | 'center' | 'end';
9
+
10
+ // lets EsMenuBarFlyout register a per-menu align value with its parent EsMenuBar,
11
+ // so the single shared Reka Viewport can re-align as the active menu changes
12
+ export const ES_MENU_BAR_ALIGNMENT_REGISTRY_KEY: InjectionKey<{
13
+ register: (id: string, align: EsMenuBarFlyoutAlign) => void;
14
+ unregister: (id: string) => void;
15
+ }> = Symbol('esMenuBarAlignmentRegistry');
16
+
17
+ /**
18
+ * Useful when rendering a non-full-width EsMenuBar with a v-for.
19
+ * Pass the result into the 'align' prop on EsMenuBarFlyout to achieve this behavior:
20
+ * - the first menu flyout will be aligned to the left edge of the trigger
21
+ * - the middle flyouts will be center aligned
22
+ * - the last menu flyout will be aligned to the right edge of the trigger
23
+ */
24
+ export const esMenuBarFlyoutDefaultAlign = (index: number, arr: any[] | undefined): EsMenuBarFlyoutAlign =>
25
+ !arr ? 'center' : index === arr.length - 1 ? 'end' : index === 0 ? 'start' : 'center';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energysage/es-ds-components",
3
- "version": "5.4.3",
3
+ "version": "5.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "An EnergySage Vue component library",
@@ -17,7 +17,7 @@
17
17
  "author": "EnergySage",
18
18
  "license": "MIT",
19
19
  "devDependencies": {
20
- "@energysage/es-ds-styles": "^3.5.3",
20
+ "@energysage/es-ds-styles": "^3.5.4",
21
21
  "@nuxt/eslint": "^1.14.0",
22
22
  "@nuxtjs/google-fonts": "^3.2.0",
23
23
  "@phosphor-icons/vue": "^2.2.1",
@@ -28,6 +28,7 @@
28
28
  "eslint-config-prettier": "^10.0.1",
29
29
  "html-truncate": "^1.2.2",
30
30
  "maska": "^3.2.0",
31
+ "mitt": "^3.0.1",
31
32
  "nuxt": "^4.3.1",
32
33
  "nuxt-primevue": "^3.0.0",
33
34
  "prettier": "^3.3.3",