@energysage/es-ds-components 5.2.0 → 5.3.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 +16 -0
- package/app/components/es-logo.vue +50 -0
- package/app/components/es-mobile-nav-content.vue +308 -0
- package/app/components/es-mobile-nav-custom-item.vue +9 -0
- package/app/components/es-mobile-nav-link.vue +62 -0
- package/app/components/es-mobile-nav-trigger.vue +29 -0
- package/app/components/es-mobile-nav.vue +219 -0
- package/app/components/es-mobile-sub-nav.vue +243 -0
- package/app/components/es-nav-cta-card.vue +113 -0
- package/app/components/es-sticky-bar.vue +2 -1
- package/app/components/icon/battery-charging-vertical.vue +16 -0
- package/app/components/icon/charging-station.vue +16 -0
- package/app/components/icon/house-line.vue +16 -0
- package/app/composables/breakpoints.ts +75 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.3.0](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.2.0...es-ds-components-v5.3.0) (2026-04-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add EsMobileNav ([#1722](https://github.com/EnergySage/es-ds/issues/1722)) ([29fcfad](https://github.com/EnergySage/es-ds/commit/29fcfada66e6386df1f58d10f783b29be416ec2e))
|
|
9
|
+
* added EsNavCtaCard for use in mobile/desktop navs ([#1723](https://github.com/EnergySage/es-ds/issues/1723)) ([74b3bbd](https://github.com/EnergySage/es-ds/commit/74b3bbd68c293b9606f9012b5d94ba1c06c60f59))
|
|
10
|
+
* added the breakpoints composable ([#1724](https://github.com/EnergySage/es-ds/issues/1724)) ([de6aa05](https://github.com/EnergySage/es-ds/commit/de6aa058e0783b865930c668b729329333994d3c))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Dependencies
|
|
14
|
+
|
|
15
|
+
* The following workspace dependencies were updated
|
|
16
|
+
* dependencies
|
|
17
|
+
* @energysage/es-ds-styles bumped from ^3.3.4 to ^3.4.0
|
|
18
|
+
|
|
3
19
|
## [5.2.0](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.1.0...es-ds-components-v5.2.0) (2026-03-27)
|
|
4
20
|
|
|
5
21
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// for dark background, set class="text-white" on this component
|
|
3
|
+
interface IProps {
|
|
4
|
+
alt?: string;
|
|
5
|
+
height?: string;
|
|
6
|
+
width?: string;
|
|
7
|
+
}
|
|
8
|
+
withDefaults(defineProps<IProps>(), {
|
|
9
|
+
alt: 'EnergySage',
|
|
10
|
+
height: '45px',
|
|
11
|
+
width: '205px',
|
|
12
|
+
});
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<svg
|
|
17
|
+
class="es-logo"
|
|
18
|
+
:style="{
|
|
19
|
+
height: height,
|
|
20
|
+
width: width,
|
|
21
|
+
}"
|
|
22
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
23
|
+
viewBox="0 0 205 45"
|
|
24
|
+
width="205"
|
|
25
|
+
height="45"
|
|
26
|
+
fill="none">
|
|
27
|
+
<title v-if="alt">
|
|
28
|
+
{{ alt }}
|
|
29
|
+
</title>
|
|
30
|
+
<!-- eslint-disable -->
|
|
31
|
+
<path
|
|
32
|
+
d="M47.929 17.293c-4.967 0-8.435 3.573-8.435 8.694 0 5.12 3.533 8.533 8.593 8.533 3.42 0 5.972-1.308 6.895-2.524a.308.308 0 0 0 .029-.327l-.114-.214a.311.311 0 0 0-.466-.097 7.103 7.103 0 0 1-4.452 1.56c-2.536 0-4.866-1.33-6.077-3.47a5.229 5.229 0 0 1-.843-2.076c-.02-.129-.06-.355-.081-.605H55.22c.17 0 .308-.138.308-.307v-.832c0-4.152-2.35-8.344-7.596-8.344v.009h-.004Zm-4.78 6.534c.635-2.306 2.337-3.619 4.715-3.619 2.71 0 3.744 1.89 4.124 3.619h-8.844.004ZM83.472 17.293c-4.967 0-8.435 3.573-8.435 8.694 0 5.12 3.533 8.533 8.593 8.533 3.42 0 5.972-1.308 6.896-2.524a.309.309 0 0 0 .028-.327l-.113-.214a.311.311 0 0 0-.466-.097 7.103 7.103 0 0 1-4.453 1.56c-2.536 0-4.866-1.33-6.077-3.47a5.23 5.23 0 0 1-.843-2.076c-.02-.129-.06-.355-.08-.605h12.243c.17 0 .308-.138.308-.307v-.832c0-4.152-2.35-8.344-7.597-8.344v.009h-.004Zm-4.784 6.534c.636-2.306 2.337-3.619 4.715-3.619 2.71 0 3.744 1.89 4.125 3.619h-8.845.004ZM197.399 17.293c-4.967 0-8.435 3.573-8.435 8.694 0 5.12 3.533 8.533 8.593 8.533 3.42 0 5.972-1.308 6.896-2.524a.308.308 0 0 0 .028-.327l-.113-.214a.311.311 0 0 0-.466-.097 7.103 7.103 0 0 1-4.452 1.56c-2.537 0-4.866-1.33-6.078-3.47-.004-.008-.008-.016-.016-.024a5.219 5.219 0 0 1-.826-2.052c-.021-.129-.061-.355-.081-.605h12.243c.17 0 .308-.138.308-.307v-.832c0-4.152-2.35-8.344-7.596-8.344v.009h-.005Zm-.064 2.915c2.71 0 3.743 1.89 4.124 3.619h-8.844c.636-2.306 2.337-3.619 4.716-3.619h.004ZM27.96 29.287h-3.7a.154.154 0 0 0-.154.153v4.693c0 .084.07.153.154.153h3.7a.154.154 0 0 0 .153-.153V29.44a.154.154 0 0 0-.154-.154Z"
|
|
33
|
+
fill="currentColor" />
|
|
34
|
+
<path
|
|
35
|
+
d="M25.553 17.228h-9.61a.306.306 0 0 1-.304-.303V.303A.306.306 0 0 0 15.335 0H13.35a.454.454 0 0 0-.414.267L3.918 20.37l8.475-13.303v17.668l3.258-4.272h8.062L11.214 40.08V26.197H1.406L0 29.3h7.973V44.7c0 .166.134.299.3.299h3.484a.294.294 0 0 0 .256-.141l13.576-21.311 9.63 10.18-9.67-16.496.004-.004Z"
|
|
36
|
+
fill="#FF9133" />
|
|
37
|
+
<path
|
|
38
|
+
d="M120.195 19.182c-1.945-1.272-4.145-1.89-6.73-1.89-2.37 0-4.57.78-6.186 2.197-1.791 1.567-2.735 3.772-2.735 6.381 0 3.166 1.325 5.165 2.435 6.28 1.43 1.438 3.375 2.261 5.332 2.261 1.523 0 3.184-.634 4.391-1.635a.166.166 0 0 1 .272.129v1.963c0 3.17-1.471 4.777-4.376 4.777-2.256 0-4.29-.856-5.352-1.587-.056-.04-.129-.053-.194-.04a.25.25 0 0 0-.162.117l-1.293 2.18a.166.166 0 0 0 .049.223c1.053.714 3.415 1.974 6.952 1.974 1.455 0 3.537-.29 5.198-1.684 1.641-1.377 2.476-3.489 2.476-6.283V19.324a.171.171 0 0 0-.077-.142Zm-3.221 1.975v7.988a.19.19 0 0 1-.024.093c-.843 1.325-2.496 2.245-4.06 2.245-1.953 0-4.858-1.494-4.858-5.609 0-2.617 1.43-5.674 5.47-5.674 1.6 0 2.787.517 3.387.816a.162.162 0 0 1 .089.15v-.009h-.004ZM139.123 17.943a.249.249 0 0 0-.215-.117h-3.233a.166.166 0 0 0-.149.092l-4.996 10.347a.168.168 0 0 1-.304-.004l-4.55-10.286a.252.252 0 0 0-.226-.15h-3.148a.246.246 0 0 0-.227.351l6.466 13.966c.02.048.02.1-.004.15l-5.202 9.68a.251.251 0 0 0 .004.245.243.243 0 0 0 .214.122h3.084a.25.25 0 0 0 .222-.138l12.272-24.016a.244.244 0 0 0-.008-.242ZM145.796 24.279c-1.531-.743-2.856-1.39-2.856-2.577 0-.864.688-1.736 2.224-1.736 1.535 0 2.889 1.127 3.253 1.518a.251.251 0 0 0 .405-.044l1.155-2.04a.158.158 0 0 0-.024-.193c-.855-.885-2.743-1.947-5.142-1.947-3.034 0-5.323 1.995-5.323 4.636 0 2.504 2.289 3.691 4.307 4.741 1.6.832 2.985 1.551 2.985 2.718 0 2.177-2.021 2.346-2.641 2.346-2.236 0-3.768-1.457-4.185-1.906a.244.244 0 0 0-.207-.076.25.25 0 0 0-.186.117l-1.289 2.076a.17.17 0 0 0 .029.21c.899.831 2.909 2.29 5.972 2.29 3.549 0 5.935-2.056 5.935-5.117 0-2.876-2.342-4.01-4.404-5.016h-.008ZM167.475 19.251a11.564 11.564 0 0 0-6.409-1.954c-4.546 0-9.148 2.935-9.148 8.54 0 4.871 3.2 8.542 7.446 8.542 1.832 0 3.651-.787 4.741-1.97a.167.167 0 0 1 .291.1l.089 1.333a.162.162 0 0 0 .166.153h2.731a.164.164 0 0 0 .166-.165V19.392a.162.162 0 0 0-.073-.137v-.004Zm-3.285 1.914v7.576s0 .04-.013.06c-.474 1.281-2.272 2.678-4.173 2.678-3.16 0-4.602-2.79-4.602-5.387 0-3.4 2.326-5.868 5.534-5.868 1.268 0 2.464.384 3.173.8a.16.16 0 0 1 .081.141ZM186.1 19.182c-1.945-1.272-4.145-1.89-6.725-1.89-2.371 0-4.571.78-6.187 2.197-1.791 1.567-2.735 3.772-2.735 6.381 0 3.166 1.325 5.165 2.435 6.28 1.43 1.438 3.375 2.261 5.332 2.261 1.523 0 3.184-.634 4.392-1.635a.166.166 0 0 1 .271.129v1.963c0 3.17-1.471 4.777-4.376 4.777-2.256 0-4.29-.856-5.351-1.587-.057-.04-.126-.053-.195-.04a.25.25 0 0 0-.162.117l-1.292 2.18a.165.165 0 0 0 .048.223c1.054.714 3.416 1.974 6.952 1.974 1.455 0 3.537-.29 5.199-1.684 1.64-1.377 2.475-3.489 2.475-6.283V19.324a.169.169 0 0 0-.077-.142h-.004Zm-7.305 12.297c-1.953 0-4.858-1.494-4.858-5.609 0-2.617 1.431-5.674 5.47-5.674 1.6 0 2.787.517 3.387.816a.162.162 0 0 1 .089.15v7.983a.19.19 0 0 1-.024.093c-.843 1.325-2.496 2.245-4.06 2.245l-.004-.004ZM72.821 34.294h-3.08a.164.164 0 0 1-.165-.165l-.073-8.8v-.012a5.33 5.33 0 0 0-.584-2.16 4.783 4.783 0 0 0-1.73-1.866c-.753-.477-1.697-.715-2.8-.715-.712 0-1.251.028-1.6.084-.291.049-.579.118-.854.21a.456.456 0 0 0-.316.433V34.12a.168.168 0 0 1-.17.166c-1.102-.008-2.111-.012-3.075-.012a.164.164 0 0 1-.167-.166v-15.33c0-.072.045-.137.114-.157.636-.202 1.454-.4 2.435-.59a19.63 19.63 0 0 1 3.699-.327c1.738 0 3.261.364 4.533 1.075 1.268.71 2.265 1.688 2.954 2.895.672 1.175 1.02 2.504 1.041 3.954v8.492a.164.164 0 0 1-.166.166v.008h.004ZM104.524 18.936a.167.167 0 0 0-.069-.226c-2.95-1.623-7.48-1.006-10.53-.085a.161.161 0 0 0-.113.158v15.33c0 .092.073.165.166.165.964 0 1.977.008 3.075.012.093 0 .17-.073.17-.165V21.307c0-.198.126-.372.312-.432.875-.271 1.305-.283 2.455-.295 1.102 0 2.046.242 2.8.715.129.08.308.258.425.064l1.305-2.427h.004v.004Z"
|
|
39
|
+
fill="currentColor" />
|
|
40
|
+
<!-- eslint-enable -->
|
|
41
|
+
</svg>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<style lang="scss" scoped>
|
|
45
|
+
@use '@energysage/es-ds-styles/scss/variables';
|
|
46
|
+
|
|
47
|
+
.es-logo {
|
|
48
|
+
color: variables.$blue-900;
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { NavigationMenuContent, NavigationMenuList } from 'reka-ui';
|
|
3
|
+
import type { ShallowRef } from 'vue';
|
|
4
|
+
|
|
5
|
+
const mobileNavParentElement = useTemplateRef('mobileNavParentElement');
|
|
6
|
+
|
|
7
|
+
// reactive flags set by hidden sr-only buttons in submenus to simulate focus on visible header buttons
|
|
8
|
+
const backButtonSimulatedFocus = ref(false);
|
|
9
|
+
const closeButtonSimulatedFocus = ref(false);
|
|
10
|
+
|
|
11
|
+
// injected variables provided from EsMobileNav
|
|
12
|
+
const animationDuration = inject('animationDuration', 200);
|
|
13
|
+
const closeMenu: (...args: any[]) => void = inject('closeMenu', () => {});
|
|
14
|
+
const currentDepth = inject(
|
|
15
|
+
'currentDepth',
|
|
16
|
+
computed(() => 0),
|
|
17
|
+
);
|
|
18
|
+
const displayedName = inject('displayedName', '');
|
|
19
|
+
const from = inject('from', ref<'left' | 'right'>('left'));
|
|
20
|
+
const goBack = inject('goBack', () => {});
|
|
21
|
+
const width = inject('width', ref(400));
|
|
22
|
+
const widthPx = inject(
|
|
23
|
+
'widthPx',
|
|
24
|
+
computed(() => '400px'),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// derived values for CSS custom properties set directly on the element
|
|
28
|
+
const animationDurationMs = `${animationDuration}ms`;
|
|
29
|
+
const contentPaneTransformXs = computed(() => `translateX(${currentDepth.value * -100}vw)`);
|
|
30
|
+
const contentPaneTransformSm = computed(() => `translateX(${currentDepth.value * -1 * width.value}px)`);
|
|
31
|
+
const left = computed(() => (from.value === 'left' ? '0' : 'auto'));
|
|
32
|
+
const menuClosedTranslateX = computed(() => (from.value === 'right' ? '100%' : '-100%'));
|
|
33
|
+
const registerScrollableContentArea = inject<
|
|
34
|
+
(areaTemplateRef: Readonly<ShallowRef<InstanceType<typeof NavigationMenuContent> | null>>) => void
|
|
35
|
+
>('registerScrollableContentArea', () => {});
|
|
36
|
+
const right = computed(() => (from.value === 'right' ? '0' : 'auto'));
|
|
37
|
+
|
|
38
|
+
// directional animation of the submenu titles as you're going down and back up
|
|
39
|
+
const direction = ref(1);
|
|
40
|
+
const transitionName = computed(() =>
|
|
41
|
+
direction.value > 0 ? 'es-mobile-nav-title-forward' : 'es-mobile-nav-title-back',
|
|
42
|
+
);
|
|
43
|
+
watch(
|
|
44
|
+
currentDepth,
|
|
45
|
+
(newVal, oldVal) => {
|
|
46
|
+
direction.value = newVal > oldVal ? 1 : -1;
|
|
47
|
+
},
|
|
48
|
+
{ flush: 'sync' },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const focusOutside = (e: any) => {
|
|
52
|
+
// if focus is on something outside of the overall menu, close it
|
|
53
|
+
if (!isElementWithinMenu(e.target)) {
|
|
54
|
+
closeMenu();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isElementWithinMenu = (element: any) => {
|
|
59
|
+
if (!mobileNavParentElement?.value?.$el) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const parent: Node = mobileNavParentElement.value.$el;
|
|
64
|
+
return parent.contains(element);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
onMounted(() => {
|
|
68
|
+
// give EsMobileNav a reference to the scrollable content area so it can set the scroll position
|
|
69
|
+
registerScrollableContentArea(mobileNavParentElement);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
provide('backButtonSimulatedFocus', backButtonSimulatedFocus);
|
|
73
|
+
provide('closeButtonSimulatedFocus', closeButtonSimulatedFocus);
|
|
74
|
+
provide('isElementWithinMenu', isElementWithinMenu);
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<!-- teleported to body to escape any ancestor stacking context (e.g. es-sticky-bar) -->
|
|
79
|
+
<teleport to="body">
|
|
80
|
+
<navigation-menu-content
|
|
81
|
+
v-bind="$attrs"
|
|
82
|
+
ref="mobileNavParentElement"
|
|
83
|
+
class="es-mobile-nav-content bg-white d-flex flex-column pb-50 position-fixed px-50"
|
|
84
|
+
:style="{
|
|
85
|
+
'--es-mobile-nav-animation-duration': animationDurationMs,
|
|
86
|
+
'--es-mobile-nav-closed-translate-x': menuClosedTranslateX,
|
|
87
|
+
'--es-mobile-nav-content-pane-transform-sm': contentPaneTransformSm,
|
|
88
|
+
'--es-mobile-nav-content-pane-transform-xs': contentPaneTransformXs,
|
|
89
|
+
'--es-mobile-nav-left': left,
|
|
90
|
+
'--es-mobile-nav-right': right,
|
|
91
|
+
'--es-mobile-nav-width': widthPx,
|
|
92
|
+
}"
|
|
93
|
+
@focus-outside.prevent.stop="focusOutside"
|
|
94
|
+
@interact-outside.prevent.stop
|
|
95
|
+
@pointer-down-outside.prevent.stop>
|
|
96
|
+
<div
|
|
97
|
+
class="es-mobile-nav-content-header align-items-center bg-white d-flex flex-shrink-0 justify-content-center position-sticky w-100">
|
|
98
|
+
<!-- back button -->
|
|
99
|
+
<transition name="es-mobile-nav-back">
|
|
100
|
+
<es-button
|
|
101
|
+
v-if="currentDepth > 0"
|
|
102
|
+
class="es-mobile-nav-back-button p-50 position-absolute text-blue-900"
|
|
103
|
+
:class="{ focus: backButtonSimulatedFocus }"
|
|
104
|
+
inline
|
|
105
|
+
variant="link"
|
|
106
|
+
@click="goBack">
|
|
107
|
+
<icon-arrow-left aria-hidden="true" />
|
|
108
|
+
<span class="sr-only">back</span>
|
|
109
|
+
</es-button>
|
|
110
|
+
</transition>
|
|
111
|
+
|
|
112
|
+
<!-- logo or subnav title, with smooth transition between them -->
|
|
113
|
+
<div class="es-mobile-nav-title-outer-container">
|
|
114
|
+
<transition :name="transitionName">
|
|
115
|
+
<div
|
|
116
|
+
:key="displayedName || 'logo'"
|
|
117
|
+
class="es-mobile-nav-title-inner-container align-items-center d-flex justify-content-center">
|
|
118
|
+
<div
|
|
119
|
+
v-if="displayedName"
|
|
120
|
+
class="font-weight-bolder">
|
|
121
|
+
{{ displayedName }}
|
|
122
|
+
</div>
|
|
123
|
+
<es-logo
|
|
124
|
+
v-else
|
|
125
|
+
alt=""
|
|
126
|
+
aria-hidden="true"
|
|
127
|
+
height="30px"
|
|
128
|
+
width="135px" />
|
|
129
|
+
</div>
|
|
130
|
+
</transition>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<!-- close button -->
|
|
134
|
+
<es-button
|
|
135
|
+
class="es-mobile-nav-close-button p-50 position-absolute text-blue-900"
|
|
136
|
+
:class="{ focus: closeButtonSimulatedFocus }"
|
|
137
|
+
inline
|
|
138
|
+
variant="link"
|
|
139
|
+
@click="closeMenu">
|
|
140
|
+
<icon-x />
|
|
141
|
+
<span class="sr-only">close</span>
|
|
142
|
+
</es-button>
|
|
143
|
+
</div>
|
|
144
|
+
<div
|
|
145
|
+
class="es-mobile-nav-content-pane d-flex flex-column flex-grow-1 justify-content-between position-relative">
|
|
146
|
+
<navigation-menu-list class="list-unstyled">
|
|
147
|
+
<slot />
|
|
148
|
+
</navigation-menu-list>
|
|
149
|
+
<div class="es-mobile-nav-content-footer">
|
|
150
|
+
<slot name="footer" />
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</navigation-menu-content>
|
|
154
|
+
</teleport>
|
|
155
|
+
</template>
|
|
156
|
+
|
|
157
|
+
<!-- unscoped: targets Reka UI-rendered elements that don't receive Vue's scoped attribute -->
|
|
158
|
+
<style lang="scss">
|
|
159
|
+
@use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
|
|
160
|
+
@use '@energysage/es-ds-styles/scss/variables';
|
|
161
|
+
|
|
162
|
+
@keyframes esMobileNavOpen {
|
|
163
|
+
from {
|
|
164
|
+
transform: translateX(var(--es-mobile-nav-closed-translate-x));
|
|
165
|
+
}
|
|
166
|
+
to {
|
|
167
|
+
transform: translateX(0);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@keyframes esMobileNavClose {
|
|
172
|
+
from {
|
|
173
|
+
transform: translateX(0);
|
|
174
|
+
}
|
|
175
|
+
to {
|
|
176
|
+
transform: translateX(var(--es-mobile-nav-closed-translate-x));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.es-mobile-nav-content {
|
|
181
|
+
--es-mobile-nav-header-height: 88px;
|
|
182
|
+
|
|
183
|
+
bottom: 0;
|
|
184
|
+
box-shadow: 0 0 6px 0 rgba(34, 38, 51, 0.2);
|
|
185
|
+
left: 0;
|
|
186
|
+
overflow-x: hidden;
|
|
187
|
+
position: fixed;
|
|
188
|
+
right: 0;
|
|
189
|
+
top: 0;
|
|
190
|
+
z-index: 1020;
|
|
191
|
+
|
|
192
|
+
@media not (prefers-reduced-motion) {
|
|
193
|
+
&[data-state='open'] {
|
|
194
|
+
animation: esMobileNavOpen var(--es-mobile-nav-animation-duration) ease-out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
&[data-state='closed'] {
|
|
198
|
+
animation: esMobileNavClose var(--es-mobile-nav-animation-duration) ease-in;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@include breakpoints.media-breakpoint-up(sm) {
|
|
203
|
+
left: var(--es-mobile-nav-left);
|
|
204
|
+
right: var(--es-mobile-nav-right);
|
|
205
|
+
width: var(--es-mobile-nav-width);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.es-mobile-nav-content-header {
|
|
210
|
+
border-bottom: 1px solid variables.$gray-100;
|
|
211
|
+
height: var(--es-mobile-nav-header-height);
|
|
212
|
+
left: 0;
|
|
213
|
+
right: 0;
|
|
214
|
+
top: 0;
|
|
215
|
+
z-index: 2;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.es-mobile-nav-back-button {
|
|
219
|
+
left: variables.$spacer * 0.5;
|
|
220
|
+
transform: translateY(-50%);
|
|
221
|
+
top: 50%;
|
|
222
|
+
|
|
223
|
+
svg {
|
|
224
|
+
height: 20px !important;
|
|
225
|
+
width: 20px !important;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.es-mobile-nav-close-button {
|
|
230
|
+
right: variables.$spacer;
|
|
231
|
+
transform: translateY(-50%);
|
|
232
|
+
top: 50%;
|
|
233
|
+
|
|
234
|
+
svg {
|
|
235
|
+
height: 20px !important;
|
|
236
|
+
width: 20px !important;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.es-mobile-nav-content-pane {
|
|
241
|
+
transform: var(--es-mobile-nav-content-pane-transform-xs);
|
|
242
|
+
|
|
243
|
+
@media not (prefers-reduced-motion) {
|
|
244
|
+
transition: transform var(--es-mobile-nav-animation-duration) ease-in-out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@include breakpoints.media-breakpoint-up(sm) {
|
|
248
|
+
transform: var(--es-mobile-nav-content-pane-transform-sm);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
</style>
|
|
252
|
+
|
|
253
|
+
<!-- scoped: targets native elements directly in this component's template -->
|
|
254
|
+
<style lang="scss" scoped>
|
|
255
|
+
.es-mobile-nav-back-enter-from,
|
|
256
|
+
.es-mobile-nav-back-leave-to {
|
|
257
|
+
opacity: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@media not (prefers-reduced-motion) {
|
|
261
|
+
.es-mobile-nav-back-enter-active,
|
|
262
|
+
.es-mobile-nav-back-leave-active {
|
|
263
|
+
transition: opacity var(--es-mobile-nav-animation-duration) ease-in-out;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.es-mobile-nav-title-outer-container {
|
|
268
|
+
/* necessary to keep the logo/titles centered as they show/hide and animate */
|
|
269
|
+
display: grid;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.es-mobile-nav-title-inner-container {
|
|
273
|
+
/* necessary to keep the logo/titles centered as they show/hide and animate */
|
|
274
|
+
grid-area: 1 / 1;
|
|
275
|
+
height: 30px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.es-mobile-nav-title-back-enter-from {
|
|
279
|
+
opacity: 0;
|
|
280
|
+
transform: translateX(-20px);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.es-mobile-nav-title-back-leave-to {
|
|
284
|
+
opacity: 0;
|
|
285
|
+
transform: translateX(20px);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.es-mobile-nav-title-forward-enter-from {
|
|
289
|
+
opacity: 0;
|
|
290
|
+
transform: translateX(20px);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.es-mobile-nav-title-forward-leave-to {
|
|
294
|
+
opacity: 0;
|
|
295
|
+
transform: translateX(-20px);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@media not (prefers-reduced-motion) {
|
|
299
|
+
.es-mobile-nav-title-forward-enter-active,
|
|
300
|
+
.es-mobile-nav-title-forward-leave-active,
|
|
301
|
+
.es-mobile-nav-title-back-enter-active,
|
|
302
|
+
.es-mobile-nav-title-back-leave-active {
|
|
303
|
+
transition:
|
|
304
|
+
opacity var(--es-mobile-nav-animation-duration) ease,
|
|
305
|
+
transform var(--es-mobile-nav-animation-duration) ease;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
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="es-mobile-nav-link d-block font-size-75 font-weight-bolder"
|
|
21
|
+
:href="href"
|
|
22
|
+
:target="target">
|
|
23
|
+
<span class="d-block p-100 rounded-sm">
|
|
24
|
+
{{ name }}
|
|
25
|
+
</span>
|
|
26
|
+
</a>
|
|
27
|
+
</navigation-menu-link>
|
|
28
|
+
</navigation-menu-item>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<style lang="scss" scoped>
|
|
32
|
+
@use '@energysage/es-ds-styles/scss/variables';
|
|
33
|
+
|
|
34
|
+
$transition-duration: 100ms;
|
|
35
|
+
|
|
36
|
+
.es-mobile-nav-link {
|
|
37
|
+
/* specify color here so it can be overridden in active state */
|
|
38
|
+
color: variables.$dark-blue;
|
|
39
|
+
|
|
40
|
+
&:hover {
|
|
41
|
+
text-decoration: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@media not (prefers-reduced-motion) {
|
|
45
|
+
transition:
|
|
46
|
+
background-color $transition-duration ease-in-out,
|
|
47
|
+
color $transition-duration ease-in-out;
|
|
48
|
+
|
|
49
|
+
& > span {
|
|
50
|
+
transition: background-color $transition-duration ease-in-out;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
&:active {
|
|
55
|
+
color: variables.$white;
|
|
56
|
+
|
|
57
|
+
& > span {
|
|
58
|
+
background-color: variables.$dark-blue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { NavigationMenuTrigger } from 'reka-ui';
|
|
3
|
+
|
|
4
|
+
interface IProps {
|
|
5
|
+
unstyled?: boolean;
|
|
6
|
+
}
|
|
7
|
+
withDefaults(defineProps<IProps>(), {
|
|
8
|
+
unstyled: false,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const triggerRef = useTemplateRef<InstanceType<typeof NavigationMenuTrigger>>('triggerRef');
|
|
12
|
+
const registerNavTrigger = inject<(el: HTMLElement) => void>('registerNavTrigger');
|
|
13
|
+
|
|
14
|
+
onMounted(() => {
|
|
15
|
+
if (registerNavTrigger && triggerRef.value) {
|
|
16
|
+
registerNavTrigger(triggerRef.value.$el);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<!-- styles defined with :deep() in EsMobileNav due to Reka UI's rendering method -->
|
|
23
|
+
<navigation-menu-trigger
|
|
24
|
+
ref="triggerRef"
|
|
25
|
+
v-bind="$attrs"
|
|
26
|
+
:class="{ 'es-mobile-nav-trigger': !unstyled }">
|
|
27
|
+
<slot />
|
|
28
|
+
</navigation-menu-trigger>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useScrollLock } from '@vueuse/core';
|
|
3
|
+
import { type NavigationMenuContent, NavigationMenuItem, NavigationMenuList, NavigationMenuRoot } from 'reka-ui';
|
|
4
|
+
import type { ShallowRef } from 'vue';
|
|
5
|
+
|
|
6
|
+
interface IProps {
|
|
7
|
+
from?: 'left' | 'right';
|
|
8
|
+
width?: number;
|
|
9
|
+
}
|
|
10
|
+
const props = withDefaults(defineProps<IProps>(), {
|
|
11
|
+
from: 'left',
|
|
12
|
+
width: 400,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ANIMATION_DURATION = 200;
|
|
16
|
+
const ANIMATION_DURATION_MS = `${ANIMATION_DURATION}ms`;
|
|
17
|
+
|
|
18
|
+
const activeMenuId = ref('');
|
|
19
|
+
const navTriggerElement = ref<HTMLElement | null>(null);
|
|
20
|
+
const nameStack: Ref<string[]> = ref([]);
|
|
21
|
+
const scrollableContentAreaTemplateRef: Ref<Readonly<
|
|
22
|
+
ShallowRef<InstanceType<typeof NavigationMenuContent> | null>
|
|
23
|
+
> | null> = ref(null);
|
|
24
|
+
const subNavCloseHandlers: Ref<Function[]> = ref([]);
|
|
25
|
+
|
|
26
|
+
const displayedName = computed(() => nameStack.value.at(-1) ?? '');
|
|
27
|
+
const isScrollLocked = useScrollLock(import.meta.client ? document.body : null);
|
|
28
|
+
const widthPx = computed(() => `${props.width}px`);
|
|
29
|
+
|
|
30
|
+
// closes the top-level menu
|
|
31
|
+
const closeMenu = () => {
|
|
32
|
+
// focus the trigger before closing so assistive technologies (e.g. VoiceOver)
|
|
33
|
+
// don't lose focus when the menu content is removed from the DOM.
|
|
34
|
+
navTriggerElement.value?.focus();
|
|
35
|
+
|
|
36
|
+
// close the menu
|
|
37
|
+
activeMenuId.value = '';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// provides a read-only variable indicating the current depth
|
|
41
|
+
const currentDepth = computed(() => nameStack.value.length);
|
|
42
|
+
|
|
43
|
+
// closes the currently-visible menu
|
|
44
|
+
const goBack = () => {
|
|
45
|
+
const closeCurrentMenu = subNavCloseHandlers.value.pop();
|
|
46
|
+
if (closeCurrentMenu) {
|
|
47
|
+
closeCurrentMenu();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// animates up/back one level
|
|
52
|
+
const decreaseDepth = () => {
|
|
53
|
+
if (currentDepth.value > 0) {
|
|
54
|
+
nameStack.value = nameStack.value.slice(0, nameStack.value.length - 1);
|
|
55
|
+
|
|
56
|
+
// if we can, scroll to the top of the content area so the parent menu starts at the top
|
|
57
|
+
if (scrollableContentAreaTemplateRef.value?.value?.$el?.scrollTop) {
|
|
58
|
+
scrollableContentAreaTemplateRef.value.value.$el.scrollTop = 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// animates down/forward one level
|
|
64
|
+
const increaseDepth = (name: string) => {
|
|
65
|
+
nameStack.value.push(name);
|
|
66
|
+
|
|
67
|
+
// if we can, scroll to the top of the content area so the submenu starts at the top
|
|
68
|
+
if (scrollableContentAreaTemplateRef.value?.value?.$el?.scrollTop) {
|
|
69
|
+
scrollableContentAreaTemplateRef.value.value.$el.scrollTop = 0;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// allows the nav trigger to register itself so closeMenu can return focus to it
|
|
74
|
+
const registerNavTrigger = (el: HTMLElement) => {
|
|
75
|
+
navTriggerElement.value = el;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// allows us to scroll the content area up to the top when opening a submenu
|
|
79
|
+
const registerScrollableContentArea = (
|
|
80
|
+
areaTemplateRef: Readonly<ShallowRef<InstanceType<typeof NavigationMenuContent> | null>>,
|
|
81
|
+
) => {
|
|
82
|
+
scrollableContentAreaTemplateRef.value = areaTemplateRef;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// provides the back button with a function to call in order to close the active submenu
|
|
86
|
+
// (this is called by submenus when they are opened)
|
|
87
|
+
const registerSubNavCloseHandler = (callback: Function) => subNavCloseHandlers.value.push(callback);
|
|
88
|
+
|
|
89
|
+
// provides a way to wait for animation to complete prior to doing something
|
|
90
|
+
const waitForAnimationDuration = async () => {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
setTimeout(() => resolve(true), ANIMATION_DURATION);
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
provide('animationDuration', ANIMATION_DURATION);
|
|
97
|
+
provide('closeMenu', closeMenu);
|
|
98
|
+
provide('currentDepth', currentDepth);
|
|
99
|
+
provide('decreaseDepth', decreaseDepth);
|
|
100
|
+
provide('displayedName', displayedName);
|
|
101
|
+
provide('from', toRef(props, 'from'));
|
|
102
|
+
provide('goBack', goBack);
|
|
103
|
+
provide('increaseDepth', increaseDepth);
|
|
104
|
+
provide('registerNavTrigger', registerNavTrigger);
|
|
105
|
+
provide('registerScrollableContentArea', registerScrollableContentArea);
|
|
106
|
+
provide('registerSubNavCloseHandler', registerSubNavCloseHandler);
|
|
107
|
+
provide('waitForAnimationDuration', waitForAnimationDuration);
|
|
108
|
+
provide('width', toRef(props, 'width'));
|
|
109
|
+
provide('widthPx', widthPx);
|
|
110
|
+
|
|
111
|
+
watch(activeMenuId, async (newVal: string, oldVal: string) => {
|
|
112
|
+
isScrollLocked.value = !!newVal;
|
|
113
|
+
|
|
114
|
+
// if the menu is closing
|
|
115
|
+
if (!newVal && oldVal) {
|
|
116
|
+
// wait until the mobile nav closing animation is complete
|
|
117
|
+
await waitForAnimationDuration();
|
|
118
|
+
|
|
119
|
+
// reset the state of all submenus by closing all open ones (without animation)
|
|
120
|
+
subNavCloseHandlers.value.forEach((callback: Function) => {
|
|
121
|
+
callback(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// clear out all subnav close handlers since all of them are closed
|
|
125
|
+
subNavCloseHandlers.value = [];
|
|
126
|
+
|
|
127
|
+
// clear nameStack to set the animation "window" to first position
|
|
128
|
+
nameStack.value = [];
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<navigation-menu-root
|
|
135
|
+
v-model="activeMenuId"
|
|
136
|
+
v-bind="$attrs"
|
|
137
|
+
class="es-mobile-nav d-flex"
|
|
138
|
+
disable-hover-trigger
|
|
139
|
+
disable-pointer-leave-close>
|
|
140
|
+
<!-- overlay -->
|
|
141
|
+
<teleport to="body">
|
|
142
|
+
<div
|
|
143
|
+
class="es-mobile-nav-overlay position-fixed"
|
|
144
|
+
:class="{
|
|
145
|
+
active: !!activeMenuId,
|
|
146
|
+
}"
|
|
147
|
+
@click="activeMenuId = ''" />
|
|
148
|
+
</teleport>
|
|
149
|
+
|
|
150
|
+
<!--
|
|
151
|
+
since the first level is a single item - the trigger to open the mobile nav -
|
|
152
|
+
prevent it from being a <ul> and <li>
|
|
153
|
+
-->
|
|
154
|
+
<navigation-menu-list
|
|
155
|
+
as="div"
|
|
156
|
+
class="es-mobile-nav-list d-flex">
|
|
157
|
+
<navigation-menu-item
|
|
158
|
+
as="div"
|
|
159
|
+
class="d-flex">
|
|
160
|
+
<slot />
|
|
161
|
+
</navigation-menu-item>
|
|
162
|
+
</navigation-menu-list>
|
|
163
|
+
</navigation-menu-root>
|
|
164
|
+
</template>
|
|
165
|
+
|
|
166
|
+
<style lang="scss" scoped>
|
|
167
|
+
@use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
|
|
168
|
+
@use '@energysage/es-ds-styles/scss/variables';
|
|
169
|
+
|
|
170
|
+
$animation-duration: v-bind(ANIMATION_DURATION_MS);
|
|
171
|
+
|
|
172
|
+
.es-mobile-nav {
|
|
173
|
+
--mobile-nav-width: v-bind(widthPx);
|
|
174
|
+
|
|
175
|
+
&-overlay {
|
|
176
|
+
opacity: 0;
|
|
177
|
+
pointer-events: none;
|
|
178
|
+
|
|
179
|
+
&.active {
|
|
180
|
+
opacity: 0.3;
|
|
181
|
+
pointer-events: all;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@include breakpoints.media-breakpoint-up(sm) {
|
|
185
|
+
background-color: variables.$gray-900;
|
|
186
|
+
bottom: 0;
|
|
187
|
+
left: 0;
|
|
188
|
+
right: 0;
|
|
189
|
+
top: 0;
|
|
190
|
+
z-index: 1010;
|
|
191
|
+
|
|
192
|
+
@media not (prefers-reduced-motion) {
|
|
193
|
+
transition: opacity $animation-duration ease-in-out;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
:deep(> div) {
|
|
199
|
+
/* ensure the button is full-height with the rest of the nav bar items */
|
|
200
|
+
display: flex;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
:deep(.es-mobile-nav-list) {
|
|
204
|
+
display: flex;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
:deep(.es-mobile-nav-trigger) {
|
|
208
|
+
-webkit-appearance: none;
|
|
209
|
+
appearance: none;
|
|
210
|
+
background: transparent;
|
|
211
|
+
border: none;
|
|
212
|
+
color: variables.$dark-blue;
|
|
213
|
+
min-height: 40px;
|
|
214
|
+
margin: 0;
|
|
215
|
+
padding: 0 variables.$spacer;
|
|
216
|
+
position: relative;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
</style>
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
NavigationMenuContent,
|
|
4
|
+
NavigationMenuItem,
|
|
5
|
+
NavigationMenuList,
|
|
6
|
+
NavigationMenuSub,
|
|
7
|
+
NavigationMenuTrigger,
|
|
8
|
+
} from 'reka-ui';
|
|
9
|
+
|
|
10
|
+
interface IProps {
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
const props = defineProps<IProps>();
|
|
14
|
+
|
|
15
|
+
const subNavParentElement = useTemplateRef('subNavParentElement');
|
|
16
|
+
const subNavTrigger = useTemplateRef('subNavTrigger');
|
|
17
|
+
|
|
18
|
+
const activeMenuId = ref('');
|
|
19
|
+
|
|
20
|
+
const backButtonSimulatedFocus = inject('backButtonSimulatedFocus', ref(false));
|
|
21
|
+
const closeButtonSimulatedFocus = inject('closeButtonSimulatedFocus', ref(false));
|
|
22
|
+
const closeMenu: (...args: any[]) => void = inject('closeMenu', () => {});
|
|
23
|
+
const decreaseDepth = inject('decreaseDepth', () => {});
|
|
24
|
+
const goBack = inject('goBack', () => {});
|
|
25
|
+
const increaseDepth: (...args: any[]) => void = inject('increaseDepth', () => {});
|
|
26
|
+
const isElementWithinMenu: (...args: any[]) => boolean = inject('isElementWithinMenu', () => true);
|
|
27
|
+
const registerSubNavCloseHandler: Function | undefined = inject('registerSubNavCloseHandler');
|
|
28
|
+
const waitForAnimationDuration = inject('waitForAnimationDuration', () => Promise.resolve(true));
|
|
29
|
+
const widthPx = inject(
|
|
30
|
+
'widthPx',
|
|
31
|
+
computed(() => '400px'),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const escapeKeyDown = (e: any) => {
|
|
35
|
+
// prevent the Esc key from immediately closing all submenus prior to the mobile nav animating closed
|
|
36
|
+
// (i.e. the visible submenu disappears, then the mobile nav animates closed, which is jarring)
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
|
|
39
|
+
// close the mobile nav in a controlled way
|
|
40
|
+
closeMenu();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleTriggerClick = () => {
|
|
44
|
+
// if the menu was open and is being closed by clicking the trigger
|
|
45
|
+
// (which should only be possible via keyboard), close the menu
|
|
46
|
+
if (activeMenuId.value) {
|
|
47
|
+
decreaseDepth();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let focusOutsideHandled = false;
|
|
52
|
+
|
|
53
|
+
const focusOutside = (e: any) => {
|
|
54
|
+
// some browsers can cycle focus through body when shifting focus, causing
|
|
55
|
+
// this handler to be fired twice. to prevent goBack() from being called
|
|
56
|
+
// more times than we want, we have this guard to prevent focusOutside
|
|
57
|
+
// from being called more than once within the same tick
|
|
58
|
+
if (focusOutsideHandled) return;
|
|
59
|
+
focusOutsideHandled = true;
|
|
60
|
+
nextTick(() => {
|
|
61
|
+
focusOutsideHandled = false;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// we only need to do anything with this submenu if it's actually open
|
|
65
|
+
if (activeMenuId.value) {
|
|
66
|
+
const classesOnTarget = [...e.target.classList];
|
|
67
|
+
|
|
68
|
+
if (classesOnTarget.includes('es-mobile-sub-nav-item-trigger')) {
|
|
69
|
+
// if the user has moved focus outside of the this submenu and onto
|
|
70
|
+
// another submenu trigger, close this submenu
|
|
71
|
+
const triggerSubNav = e.target.closest('.es-mobile-sub-nav');
|
|
72
|
+
if (triggerSubNav !== subNavParentElement?.value?.$el) {
|
|
73
|
+
goBack();
|
|
74
|
+
}
|
|
75
|
+
} else if (!isElementWithinMenu(e.target)) {
|
|
76
|
+
// if focus is on something outside of the overall menu, close it
|
|
77
|
+
closeMenu();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleSrBackClick = () => {
|
|
83
|
+
// explicitly focus the trigger before closing the submenu. without this,
|
|
84
|
+
// assistive technologies (e.g. VoiceOver) lose focus when the submenu content
|
|
85
|
+
// (which contains the sr-only button) is removed from the DOM.
|
|
86
|
+
(subNavTrigger.value as any)?.$el?.focus();
|
|
87
|
+
goBack();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const subNavCloseMenu = async (waitForAnimation: boolean = true) => {
|
|
91
|
+
// start animating up to the parent menu
|
|
92
|
+
decreaseDepth();
|
|
93
|
+
|
|
94
|
+
// wait for animation to complete
|
|
95
|
+
if (waitForAnimation) {
|
|
96
|
+
await waitForAnimationDuration();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// close the menu
|
|
100
|
+
activeMenuId.value = '';
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
watch(activeMenuId, (newVal: string, oldVal: string) => {
|
|
104
|
+
// if the menu is opening
|
|
105
|
+
if (newVal && !oldVal) {
|
|
106
|
+
increaseDepth(props.name);
|
|
107
|
+
|
|
108
|
+
if (registerSubNavCloseHandler) {
|
|
109
|
+
registerSubNavCloseHandler(subNavCloseMenu);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<template>
|
|
116
|
+
<navigation-menu-item>
|
|
117
|
+
<navigation-menu-sub
|
|
118
|
+
ref="subNavParentElement"
|
|
119
|
+
v-bind="$attrs"
|
|
120
|
+
v-model="activeMenuId"
|
|
121
|
+
class="es-mobile-sub-nav"
|
|
122
|
+
orientation="vertical">
|
|
123
|
+
<navigation-menu-trigger
|
|
124
|
+
ref="subNavTrigger"
|
|
125
|
+
class="es-mobile-sub-nav-item-trigger d-block font-size-75 font-weight-bolder m-0 p-0 text-left w-100"
|
|
126
|
+
@click="handleTriggerClick">
|
|
127
|
+
<span class="d-flex justify-content-between p-100 rounded-sm w-100">
|
|
128
|
+
{{ name }}
|
|
129
|
+
<icon-chevron-right
|
|
130
|
+
height="20px"
|
|
131
|
+
width="20px" />
|
|
132
|
+
</span>
|
|
133
|
+
</navigation-menu-trigger>
|
|
134
|
+
<navigation-menu-content
|
|
135
|
+
v-bind="$attrs"
|
|
136
|
+
class="es-mobile-sub-nav-item-content bg-white"
|
|
137
|
+
@escape-key-down="escapeKeyDown"
|
|
138
|
+
@focus-outside.prevent.stop="focusOutside"
|
|
139
|
+
@interact-outside.prevent.stop
|
|
140
|
+
@pointer-down-outside.prevent.stop>
|
|
141
|
+
<navigation-menu-list class="es-mobile-sub-nav-list list-unstyled m-0 p-0">
|
|
142
|
+
<slot />
|
|
143
|
+
</navigation-menu-list>
|
|
144
|
+
|
|
145
|
+
<!--
|
|
146
|
+
back button for keyboard / screen readers only,
|
|
147
|
+
so you can go back when in this subnav's focus trap.
|
|
148
|
+
when given focus, the visible back button in the
|
|
149
|
+
nav content header will get a visible focus outline.
|
|
150
|
+
-->
|
|
151
|
+
<es-button
|
|
152
|
+
class="sr-only"
|
|
153
|
+
inline
|
|
154
|
+
variant="link"
|
|
155
|
+
@blur="backButtonSimulatedFocus = false"
|
|
156
|
+
@click="handleSrBackClick"
|
|
157
|
+
@focus="backButtonSimulatedFocus = true">
|
|
158
|
+
Back
|
|
159
|
+
</es-button>
|
|
160
|
+
|
|
161
|
+
<!--
|
|
162
|
+
close button for keyboard / screen readers only,
|
|
163
|
+
so you can go back when in this subnav's focus trap.
|
|
164
|
+
when given focus, the visible close button in the
|
|
165
|
+
nav content header will get a visible focus outline.
|
|
166
|
+
-->
|
|
167
|
+
<es-button
|
|
168
|
+
class="sr-only"
|
|
169
|
+
inline
|
|
170
|
+
variant="link"
|
|
171
|
+
@blur="closeButtonSimulatedFocus = false"
|
|
172
|
+
@click="closeMenu"
|
|
173
|
+
@focus="closeButtonSimulatedFocus = true">
|
|
174
|
+
Close
|
|
175
|
+
</es-button>
|
|
176
|
+
</navigation-menu-content>
|
|
177
|
+
</navigation-menu-sub>
|
|
178
|
+
</navigation-menu-item>
|
|
179
|
+
</template>
|
|
180
|
+
|
|
181
|
+
<style lang="scss" scoped>
|
|
182
|
+
@use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
|
|
183
|
+
@use '@energysage/es-ds-styles/scss/variables';
|
|
184
|
+
|
|
185
|
+
$transition-duration: 100ms;
|
|
186
|
+
|
|
187
|
+
.es-mobile-sub-nav {
|
|
188
|
+
:deep(.es-mobile-sub-nav-list) {
|
|
189
|
+
li {
|
|
190
|
+
margin: 0;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
:deep(.es-mobile-sub-nav-item-trigger) {
|
|
195
|
+
-webkit-appearance: none;
|
|
196
|
+
appearance: none;
|
|
197
|
+
background: transparent;
|
|
198
|
+
border: none;
|
|
199
|
+
border-bottom: 1px solid variables.$gray-100;
|
|
200
|
+
/* specify color here so it can be overridden in active state */
|
|
201
|
+
color: variables.$dark-blue;
|
|
202
|
+
|
|
203
|
+
@media not (prefers-reduced-motion) {
|
|
204
|
+
transition:
|
|
205
|
+
border-color $transition-duration ease-in-out,
|
|
206
|
+
color $transition-duration ease-in-out;
|
|
207
|
+
|
|
208
|
+
& > span {
|
|
209
|
+
transition: background-color $transition-duration ease-in-out;
|
|
210
|
+
|
|
211
|
+
& > svg {
|
|
212
|
+
transition: color $transition-duration ease-in-out;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
&[data-state='open'] {
|
|
217
|
+
border-bottom-color: transparent;
|
|
218
|
+
color: variables.$white;
|
|
219
|
+
|
|
220
|
+
& > span {
|
|
221
|
+
background-color: variables.$dark-blue;
|
|
222
|
+
|
|
223
|
+
& > svg {
|
|
224
|
+
color: variables.$white;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
:deep(.es-mobile-sub-nav-item-content[data-state='open']) {
|
|
232
|
+
bottom: 0;
|
|
233
|
+
left: 100vw;
|
|
234
|
+
position: absolute;
|
|
235
|
+
top: 0;
|
|
236
|
+
width: 100%;
|
|
237
|
+
|
|
238
|
+
@include breakpoints.media-breakpoint-up(sm) {
|
|
239
|
+
left: v-bind(widthPx);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
</style>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
heading: string;
|
|
4
|
+
href: string;
|
|
5
|
+
subtitle?: string;
|
|
6
|
+
target?: string;
|
|
7
|
+
}
|
|
8
|
+
withDefaults(defineProps<Props>(), {
|
|
9
|
+
subtitle: '',
|
|
10
|
+
target: '_self',
|
|
11
|
+
});
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<es-card
|
|
16
|
+
class="es-nav-cta-card d-flex p-100"
|
|
17
|
+
:class="{
|
|
18
|
+
'align-items-center': !subtitle,
|
|
19
|
+
}"
|
|
20
|
+
:href="href"
|
|
21
|
+
:target="target"
|
|
22
|
+
variant="interactive">
|
|
23
|
+
<div
|
|
24
|
+
v-if="$slots.icon"
|
|
25
|
+
class="mr-100">
|
|
26
|
+
<div class="es-nav-cta-card-icon bg-blue-50 p-50 position-relative rounded-xs text-blue-600">
|
|
27
|
+
<slot name="icon" />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div>
|
|
31
|
+
<p class="align-items-center d-flex font-weight-bold mb-0 text-blue-600">
|
|
32
|
+
{{ heading }}
|
|
33
|
+
<icon-arrow-right
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
class="es-nav-cta-card-arrow flex-shrink-0 ml-25" />
|
|
36
|
+
</p>
|
|
37
|
+
<p
|
|
38
|
+
v-if="subtitle"
|
|
39
|
+
class="font-size-75 mb-0">
|
|
40
|
+
{{ subtitle }}
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
</es-card>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<style lang="scss" scoped>
|
|
47
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
48
|
+
@use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
|
|
49
|
+
|
|
50
|
+
.es-nav-cta-card {
|
|
51
|
+
border-width: 1px;
|
|
52
|
+
|
|
53
|
+
/* only apply a hover state if the device supports mouse hover */
|
|
54
|
+
@media (hover) {
|
|
55
|
+
&:hover {
|
|
56
|
+
border-color: variables.$blue-600;
|
|
57
|
+
|
|
58
|
+
.es-nav-cta-card-icon {
|
|
59
|
+
color: variables.$white !important;
|
|
60
|
+
|
|
61
|
+
&::after {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.es-nav-cta-card-arrow {
|
|
67
|
+
margin-left: 0.75rem !important;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&-icon {
|
|
73
|
+
/* you cannot animate radial gradient backgrounds, so apply it to a pseudo element and fade it in on hover */
|
|
74
|
+
&::after {
|
|
75
|
+
background: radial-gradient(
|
|
76
|
+
156.04% 117.56% at 0% 0%,
|
|
77
|
+
variables.$blue-500 0%,
|
|
78
|
+
#5a4386 53.85%,
|
|
79
|
+
#1c1767 100%
|
|
80
|
+
);
|
|
81
|
+
border-radius: 0.25rem;
|
|
82
|
+
bottom: 0;
|
|
83
|
+
content: '';
|
|
84
|
+
left: 0;
|
|
85
|
+
opacity: 0;
|
|
86
|
+
position: absolute;
|
|
87
|
+
right: 0;
|
|
88
|
+
top: 0;
|
|
89
|
+
z-index: 2;
|
|
90
|
+
|
|
91
|
+
@media not (prefers-reduced-motion) {
|
|
92
|
+
transition: variables.$transition-base;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ensure the icon appears above the gradient pseudo element */
|
|
97
|
+
:deep(svg) {
|
|
98
|
+
position: relative;
|
|
99
|
+
z-index: 3;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@media not (prefers-reduced-motion) {
|
|
103
|
+
transition: variables.$transition-base;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
&-arrow {
|
|
108
|
+
@media not (prefers-reduced-motion) {
|
|
109
|
+
transition: variables.$transition-base;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -115,7 +115,8 @@ onUnmounted(() => {
|
|
|
115
115
|
<div
|
|
116
116
|
ref="bar"
|
|
117
117
|
class="es-sticky-bar bg-white"
|
|
118
|
-
:class="[`es-sticky-bar--${barState}`, { 'es-sticky-bar--no-transition': suppressTransition }]"
|
|
118
|
+
:class="[`es-sticky-bar--${barState}`, { 'es-sticky-bar--no-transition': suppressTransition }]"
|
|
119
|
+
v-bind="$attrs">
|
|
119
120
|
<slot />
|
|
120
121
|
</div>
|
|
121
122
|
<!-- holds the bar's space in normal flow when the bar is absolutely or fixed positioned -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PhBatteryChargingVertical } 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-battery-charging-vertical :size="width" />
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PhChargingStation } 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-charging-station :size="width" />
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PhHouseLine } 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-house-line :size="width" />
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
|
3
|
+
import sassBreakpoints from '@energysage/es-ds-styles/scss/modules/breakpoints.module.scss';
|
|
4
|
+
|
|
5
|
+
interface Breakpoints {
|
|
6
|
+
[key: string]: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const BREAKPOINTS: Breakpoints = {
|
|
10
|
+
xs: sassBreakpoints.xs!,
|
|
11
|
+
sm: sassBreakpoints.sm!,
|
|
12
|
+
md: sassBreakpoints.md!,
|
|
13
|
+
lg: sassBreakpoints.lg!,
|
|
14
|
+
xl: sassBreakpoints.xl!,
|
|
15
|
+
xxl: sassBreakpoints.xxl!,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* This composable provides reactive properties for each breakpoint, as well as device categories.
|
|
20
|
+
* It should only be used for functional purposes, and not for styling or layout.
|
|
21
|
+
* Meaning, don't use it to show/hide elements, or to change the layout of elements.
|
|
22
|
+
*/
|
|
23
|
+
export function useBreakpointChecks() {
|
|
24
|
+
const breakpointMediaQueries: { [key in keyof Breakpoints]?: MediaQueryList } = {};
|
|
25
|
+
const breakpointChangeListeners: { [key: string]: () => void } = {};
|
|
26
|
+
const breakpointMatches: { [key: string]: Ref<boolean> } = {};
|
|
27
|
+
|
|
28
|
+
if (import.meta.client) {
|
|
29
|
+
// Set up media queries, reactive references and event listeners for each breakpoint
|
|
30
|
+
Object.entries(BREAKPOINTS).forEach(([key, value]) => {
|
|
31
|
+
const mediaQueryList = window.matchMedia(`(min-width: ${value})`) as MediaQueryList;
|
|
32
|
+
breakpointMediaQueries[key] = mediaQueryList;
|
|
33
|
+
breakpointMatches[key] = ref<boolean>(mediaQueryList?.matches || false);
|
|
34
|
+
breakpointChangeListeners[key] = () => {
|
|
35
|
+
breakpointMatches[key]!.value = mediaQueryList?.matches || false;
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// On component mount, add change event listeners for each breakpoint
|
|
41
|
+
onMounted(() => {
|
|
42
|
+
Object.keys(breakpointMatches).forEach((key) => {
|
|
43
|
+
breakpointMediaQueries[key]?.addEventListener('change', breakpointChangeListeners[key]!);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// On component unmount, remove the event listeners
|
|
48
|
+
onUnmounted(() => {
|
|
49
|
+
Object.keys(breakpointMatches).forEach((key) => {
|
|
50
|
+
breakpointMediaQueries[key]?.removeEventListener('change', breakpointChangeListeners[key]!);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Individual breakpoint computed properties
|
|
55
|
+
const isXS = computed(() => breakpointMatches.xs?.value || false);
|
|
56
|
+
const isSM = computed(() => breakpointMatches.sm?.value || false);
|
|
57
|
+
const isMD = computed(() => breakpointMatches.md?.value || false);
|
|
58
|
+
const isLG = computed(() => breakpointMatches.lg?.value || false);
|
|
59
|
+
const isXL = computed(() => breakpointMatches.xl?.value || false);
|
|
60
|
+
const isXXL = computed(() => breakpointMatches.xxl?.value || false);
|
|
61
|
+
|
|
62
|
+
// Compute values for device categories
|
|
63
|
+
const isDesktop = computed(() => breakpointMatches.lg?.value || false);
|
|
64
|
+
const isTablet = computed(() => (breakpointMatches.md?.value && !breakpointMatches.lg?.value) || false);
|
|
65
|
+
const isMobile = computed(
|
|
66
|
+
() =>
|
|
67
|
+
((breakpointMatches.xs?.value || breakpointMatches.sm?.value) &&
|
|
68
|
+
!breakpointMatches.lg?.value &&
|
|
69
|
+
!breakpointMatches.md?.value) ||
|
|
70
|
+
false,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// TODO: Be nice to namespace this but it ends up being a cyclic dependency somehow
|
|
74
|
+
return { isXS, isSM, isMD, isLG, isXL, isXXL, isDesktop, isTablet, isMobile };
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energysage/es-ds-components",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.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
|
"dependencies": {
|
|
20
|
-
"@energysage/es-ds-styles": "^3.
|
|
20
|
+
"@energysage/es-ds-styles": "^3.4.0",
|
|
21
21
|
"@nuxtjs/google-fonts": "^3.2.0",
|
|
22
22
|
"@phosphor-icons/vue": "^2.2.1",
|
|
23
23
|
"@vuelidate/core": "^2.0.3",
|