@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 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,9 @@
1
+ <script setup lang="ts">
2
+ import { NavigationMenuItem } from 'reka-ui';
3
+ </script>
4
+
5
+ <template>
6
+ <navigation-menu-item class="my-150">
7
+ <slot />
8
+ </navigation-menu-item>
9
+ </template>
@@ -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.2.0",
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.3.4",
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",