@ucd-lib/theme-elements 1.1.4 → 1.2.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.
@@ -0,0 +1,587 @@
1
+ import { LitElement, html } from 'lit';
2
+ import {render, styles} from "./ucdlib-primary-nav.tpl.js";
3
+ import { styleMap } from 'lit/directives/style-map.js';
4
+ import { classMap } from 'lit/directives/class-map.js';
5
+ import { ifDefined } from 'lit/directives/if-defined.js';
6
+
7
+ import { Mixin, NavElement } from "../../utils/mixins";
8
+ import { MutationObserverController, BreakPointsController } from '../../utils/controllers';
9
+
10
+ /**
11
+ * @class UcdlibPrimaryNav
12
+ * @classdesc Component class for displaying a primary site nav
13
+ * @property {String} navType - The primary style type of the nav:
14
+ * 'superfish' - The default
15
+ * 'mega' - Hovering over any top-level link opens a single nav with all subnav links
16
+ * @property {String} styleModifiers - Apply alternate styles with a space-separated list.
17
+ * e.g. 'justify' for 'primary-nav--justify'
18
+ * @property {Number} hoverDelay - How long (ms) after hover will menu open/close
19
+ * @property {Number} animationDuration - How long (ms) for a menu to fade in/out
20
+ * @property {Number} maxDepth - Maximum number of submenus to show
21
+ * @property {Number} mobileWidth - Screen width for mobile header display, defaults to 755
22
+ *
23
+ * @example
24
+ * <ucdlib-primary-nav mobile-width="42">
25
+ * <a href="#">link 1</a>
26
+ * <a href="#">link 2</a>
27
+ * <ul link-title="link with subnav" href="#">
28
+ * <li><a href="#">subnav link 1</a></li>
29
+ * </ul>
30
+ * </ucdlib-primary-nav>
31
+ */
32
+ export default class UcdlibPrimaryNav extends Mixin(LitElement)
33
+ .with(NavElement) {
34
+
35
+ static get properties() {
36
+ return {
37
+ navType: {type: String, attribute: "nav-type"},
38
+ styleModifiers: {type: String, attribute: "style-modifiers"},
39
+ hoverDelay: {type: Number, attribute: "hover-delay"},
40
+ animationDuration: {type: Number, attribute: "animation-duration"},
41
+ navItems: {type: Array},
42
+ maxDepth: {type: Number, attribute: "max-depth"},
43
+ mobileWidth: {type: Number, attribute: "mobile-width"},
44
+ _megaIsOpen: {type: Boolean, state: true}
45
+ };
46
+ }
47
+
48
+ static get styles() {
49
+ return styles();
50
+ }
51
+
52
+ constructor() {
53
+ super();
54
+ this.render = render.bind(this);
55
+ this.mutationObserver = new MutationObserverController(this, {subtree: true, childList: true});
56
+ this.breakPoints = new BreakPointsController(this, 755); // override default mobile screen width
57
+
58
+ this.navType = "superfish";
59
+ this.styleModifiers = "";
60
+ this.hoverDelay = 300;
61
+ this.animationDuration = 300;
62
+ this.mobileWidth = 755;
63
+
64
+ this._classPrefix = "primary-nav";
65
+ this._acceptedNavTypes = ['superfish', 'mega'];
66
+ this._megaIsOpen = false;
67
+ }
68
+
69
+ /**
70
+ * @method openMegaNav
71
+ * @description Opens the meganav menu
72
+ */
73
+ openMegaNav() {
74
+ this._megaIsOpen = true;
75
+ }
76
+
77
+ /**
78
+ * @method closeMegaNav
79
+ * @description Closes the meganav menu
80
+ */
81
+ closeMegaNav(){
82
+ this._megaIsOpen = false;
83
+ }
84
+
85
+ /**
86
+ * @method openSubNav
87
+ * @description Opens the specified subnav
88
+ * @param {Array} navLocation - Coordinates of the item in the 'navItems' array. i.e. [0, 1, 4].
89
+ */
90
+ async openSubNav(navLocation){
91
+
92
+ // non-mega menu
93
+ if (
94
+ typeof navLocation !== 'object' ||
95
+ !Array.isArray(navLocation) ||
96
+ navLocation.length === 0
97
+ ) return;
98
+ let navItem = this.getNavItem(navLocation);
99
+ if ( !navItem ) return;
100
+
101
+ // Open on mobile
102
+ if ( this.breakPoints.isMobile() ) {
103
+ let nav = this.renderRoot.getElementById(`nav--${navLocation.join("-")}`);
104
+ if ( !nav ) return;
105
+ let ul = nav.querySelector('ul');
106
+ if ( !ul ) return;
107
+ if ( navItem.isTransitioning ) return;
108
+ navItem.isTransitioning = true;
109
+
110
+ // Get expanded height
111
+ navItem.inlineStyles.display = "block";
112
+ navItem.inlineStyles.height = 0 + "px";
113
+ this.requestUpdate();
114
+ await this.updateComplete;
115
+ const expandedHeight = ul.scrollHeight + "px";
116
+
117
+ // Set expanded height
118
+ navItem.inlineStyles.height = expandedHeight;
119
+ this.requestUpdate();
120
+ await this.updateComplete;
121
+
122
+ // Remove transition state after animation duration
123
+ this._completeMobileTransition(navItem);
124
+
125
+
126
+ // Open on desktop
127
+ } else {
128
+
129
+ // mega menu
130
+ if ( this.isMegaMenu() ){
131
+ return;
132
+ }
133
+
134
+ this.clearItemInlineStyles(navItem);
135
+ if ( navItem.isClosing ) {
136
+ navItem.isClosing = false;
137
+ this.requestUpdate();
138
+ }
139
+ if ( navItem.timeout ) clearTimeout(navItem.timeout);
140
+ if ( navItem.isOpen ) return;
141
+
142
+ navItem.timeout = setTimeout(() => {
143
+ navItem.isOpen = true;
144
+ this.requestUpdate();
145
+ }, this.hoverDelay);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * @method closeSubNav
151
+ * @description Closes a subnav given its coordinates
152
+ * @param {Array} navLocation - Coordinates of the item in the 'navItems' array. i.e. [0, 1, 4].
153
+ */
154
+ async closeSubNav(navLocation){
155
+
156
+ if (
157
+ typeof navLocation !== 'object' ||
158
+ !Array.isArray(navLocation) ||
159
+ navLocation.length === 0
160
+ ) return;
161
+ let navItem = this.getNavItem(navLocation);
162
+ if ( !navItem ) return;
163
+
164
+ // close on mobile
165
+ if ( this.breakPoints.isMobile() ) {
166
+ let nav = this.renderRoot.getElementById(`nav--${navLocation.join("-")}`);
167
+ if ( !nav ) return;
168
+ let ul = nav.querySelector('ul');
169
+ if ( !ul ) return;
170
+ if ( navItem.isTransitioning ) return;
171
+ navItem.isTransitioning = true;
172
+
173
+ // Set expanded height
174
+ navItem.inlineStyles.height = ul.scrollHeight + "px";
175
+ navItem.inlineStyles.display = "block";
176
+ this.requestUpdate();
177
+ await this.updateComplete;
178
+
179
+ // Set height to 0 by requesting all of the animation frames :-(
180
+ requestAnimationFrame(() => {
181
+ requestAnimationFrame(() => {
182
+ navItem.inlineStyles.height = "0px";
183
+ this.requestUpdate();
184
+
185
+ requestAnimationFrame(() => {
186
+ // Remove transition state after animation duration
187
+ this._completeMobileTransition(navItem);
188
+ });
189
+
190
+ });
191
+ });
192
+
193
+
194
+ // close on desktop
195
+ } else {
196
+
197
+ // mega menu
198
+ if ( this.isMegaMenu() ){
199
+ return;
200
+ }
201
+
202
+
203
+ this.clearItemInlineStyles(navItem);
204
+ if ( navItem.timeout ) clearTimeout(navItem.timeout);
205
+ if ( !navItem.isOpen ) return;
206
+
207
+ navItem.isClosing = true;
208
+ this.requestUpdate();
209
+ navItem.timeout = setTimeout(() => {
210
+ navItem.isOpen = false;
211
+ navItem.isClosing = false;
212
+ this.requestUpdate();
213
+ }, this.hoverDelay + this.animationDuration);
214
+ }
215
+
216
+ }
217
+
218
+ /**
219
+ * @method closeAllSubNavs
220
+ * @description Recursively closes all nav submenus within specified menu.
221
+ * @param {Array} navItems - The subItems property of any object within the 'navItems' element property.
222
+ * @param {Boolean} requestUpdate - Should an update be requested after each subnav closing?
223
+ */
224
+ closeAllSubNavs(navItems, requestUpdate=true){
225
+ if ( !navItems ) navItems = this.navItems;
226
+ navItems.forEach((navItem) => {
227
+ if ( navItem.isOpen ) {
228
+ navItem.isOpen = false;
229
+ if ( requestUpdate ) this.requestUpdate();
230
+ }
231
+ if ( navItem.subItems ) {
232
+ this.closeAllSubNavs(navItem.subItems);
233
+ }
234
+ });
235
+ }
236
+
237
+ /**
238
+ * @method isMegaMenu
239
+ * @description Does this element use the mega menu?
240
+ * @returns {Boolean}
241
+ */
242
+ isMegaMenu(){
243
+ if ( this.navType.toLowerCase().trim() === 'mega') return true;
244
+ return false;
245
+ }
246
+
247
+ /**
248
+ * @method _getNavClasses
249
+ * @private
250
+ * @description Get classes to be applied to the top-level 'nav' element
251
+ * @returns {String}
252
+ */
253
+ _getNavClasses(){
254
+ let navType = this._acceptedNavTypes[0];
255
+ if ( this._acceptedNavTypes.includes(this.navType.toLowerCase()) ) navType = this.navType;
256
+
257
+ let styleModifiers = "";
258
+ if ( this.styleModifiers ) {
259
+ styleModifiers = this.styleModifiers.split(" ").map(mod => `${this._classPrefix}--${mod}`).join(" ");
260
+ }
261
+ let megaIsOpen = this.isMegaMenu() && this._megaIsOpen ? 'is-hover' : '';
262
+ return `${this._classPrefix} ${this._classPrefix}--${navType} ${styleModifiers} ${megaIsOpen}`;
263
+ }
264
+
265
+ /**
266
+ * @method _onChildListMutation
267
+ * @private
268
+ * @description Fires when light dom child list changes. Injected by MutationObserverController.
269
+ * Sets the 'navItems' property.
270
+ */
271
+ _onChildListMutation(){
272
+ let navItems = this.parseNavChildren();
273
+ if ( navItems.length ) this.navItems = navItems;
274
+ }
275
+
276
+ /**
277
+ * @method _renderNavItem
278
+ * @private
279
+ * @description Renders a menu item and all its children to the specified max depth
280
+ * @param {Object} navItem - An item from the 'navItems' element property
281
+ * @param {Array} location - Coordinates of the item in the 'navItems' array. i.e. [0, 1, 4]
282
+ * @returns {TemplateResult}
283
+ */
284
+ _renderNavItem(navItem, location){
285
+ const depth = location.length - 1;
286
+
287
+ // Render item and its subnav
288
+ if ( this.itemHasSubNav(navItem) && depth < this.maxDepth) {
289
+ return html`
290
+ <li
291
+ id="nav--${location.join("-")}"
292
+ .key=${location}
293
+ .hasnav=${true}
294
+ @mouseenter=${this._onItemMouseenter}
295
+ @mouseleave=${this._onItemMouseleave}
296
+ class=${classMap(this._makeLiClassMap(navItem, depth))}>
297
+ <div class="submenu-toggle__wrapper ${depth === 0 ? `${this._classPrefix}__top-link` : ''}">
298
+ <a
299
+ href=${ifDefined(navItem.href ? navItem.href : null)}
300
+ tabindex=${this._setTabIndex(depth)}
301
+ @focus=${this._onItemFocus}>
302
+ ${navItem.linkText}<span class="${this._classPrefix}__submenu-indicator"></span>
303
+ </a>
304
+ <button
305
+ @click=${() => this._toggleMobileMenu(location)}
306
+ class="submenu-toggle ${navItem.isOpen ? 'submenu-toggle--open' : ''}"
307
+ ?disabled=${navItem.isTransitioning}
308
+ aria-label="Toggle Submenu">
309
+ <span class="submenu-toggle__icon"></span>
310
+ </button>
311
+ </div>
312
+ <ul class="menu ${navItem.isOpen ? "menu--open" : ""}" style=${styleMap(this._getItemMobileStyles(location))}>
313
+ ${navItem.subItems.map((subItem, i) => this._renderNavItem(subItem, location.concat([i])))}
314
+ </ul>
315
+ </li>
316
+ `;
317
+ }
318
+
319
+ // render as normal link
320
+ return html`
321
+ <li id="nav--${location.join("-")}" .key=${location} class=${classMap(this._makeLiClassMap(navItem, depth))}>
322
+ <div class="${depth === 0 ? `${this._classPrefix}__top-link`: '' }">
323
+ ${navItem.href ? html`
324
+ <a
325
+ href=${navItem.href}
326
+ @focus=${this._onItemFocus}
327
+ tabindex=${this._setTabIndex(depth)}>
328
+ ${navItem.linkText}</a>
329
+ ` : html`
330
+ <span class="${this._classPrefix}__nolink">${navItem.linkText}</span>
331
+ `}
332
+ </div>
333
+ </li>
334
+ `;
335
+ }
336
+
337
+ /**
338
+ * @method _setTabIndex
339
+ * @private
340
+ * @description Sets the tab index of menu links
341
+ * @param {Number} depth - Level of the menu link
342
+ * @returns {Number}
343
+ */
344
+ _setTabIndex(depth=0){
345
+ let i = 0;
346
+ if (
347
+ this.isMegaMenu() &&
348
+ depth > 0 &&
349
+ !this._megaIsOpen &&
350
+ this.breakPoints.isDesktop()
351
+ ) i = -1;
352
+
353
+ return i;
354
+ }
355
+
356
+ /**
357
+ * @method _makeLiClassMap
358
+ * @private
359
+ * @description Classes to be assigned to each LI element in the nav.
360
+ * @param {Object} navItem - An item in the navItems property.
361
+ * @param {Number} depth - Depth of the navItem
362
+ * @returns {Object}
363
+ */
364
+ _makeLiClassMap(navItem, depth=0){
365
+ let classes = {};
366
+ classes[`depth-${depth}`] = true;
367
+ if ( navItem.isOpen ) classes['sf--hover'] = true;
368
+ if ( navItem.isClosing ) classes.closing = true;
369
+ if (navItem.megaFocus) classes['mega-focus'] = true;
370
+ return classes;
371
+ }
372
+
373
+ /**
374
+ * @method _toggleMobileMenu
375
+ * @private
376
+ * @description Expands/collapses mobile subnavs with animation on user click.
377
+ * @param {Array} navLocation - Array coordinates of corresponding nav item
378
+ */
379
+ async _toggleMobileMenu(navLocation){
380
+ if ( this.breakPoints.isDesktop() ) return;
381
+ let navItem = this.getNavItem(navLocation);
382
+ if ( navItem.isOpen ) {
383
+ this.closeSubNav(navLocation);
384
+ } else {
385
+ this.openSubNav(navLocation);
386
+ }
387
+ }
388
+
389
+ /**
390
+ * @method _onNavMouseenter
391
+ * @private
392
+ * @description Attached to top-level nav element. Opens mega menu in desktop view
393
+ */
394
+ _onNavMouseenter(){
395
+ if (
396
+ this.breakPoints.isMobile() ||
397
+ !this.isMegaMenu() )
398
+ return;
399
+
400
+ if ( this._megaTimeout ) clearTimeout(this._megaTimeout);
401
+ this._megaTimeout = setTimeout(() => {
402
+ this.openMegaNav();
403
+ }, this.hoverDelay);
404
+ }
405
+
406
+ /**
407
+ * @method _onNavMouseleave
408
+ * @private
409
+ * @description Attached to top-level nav element. Closes mega menu in desktop view
410
+ */
411
+ _onNavMouseleave(){
412
+ if (
413
+ this.breakPoints.isMobile() ||
414
+ !this.isMegaMenu() )
415
+ return;
416
+
417
+ if ( this._megaTimeout ) clearTimeout(this._megaTimeout);
418
+
419
+ this._megaTimeout = setTimeout(() => {
420
+ this.closeMegaNav();
421
+ }, this.hoverDelay);
422
+ }
423
+
424
+ /**
425
+ * @method _onNavFocusin
426
+ * @private
427
+ * @description Fires when focus enters the main nav element. Used to open the meganav
428
+ */
429
+ _onNavFocusin(){
430
+ if (
431
+ this.breakPoints.isMobile() ||
432
+ !this.isMegaMenu() )
433
+ return;
434
+
435
+ if ( this._megaIsOpen ) return;
436
+ if ( this._megaTimeout ) clearTimeout(this._megaTimeout);
437
+
438
+ this._megaTimeout = setTimeout(() => {
439
+ this.openMegaNav();
440
+ }, this.hoverDelay);
441
+
442
+ }
443
+
444
+
445
+ /**
446
+ * @method _onItemMouseenter
447
+ * @private
448
+ * @description Bound to nav li items with a subnav
449
+ * @param {Event} e
450
+ */
451
+ _onItemMouseenter(e){
452
+ if ( this.breakPoints.isMobile() ) return;
453
+ this.openSubNav(e.target.key);
454
+ }
455
+
456
+ /**
457
+ * @method _onItemFocus
458
+ * @private
459
+ * @description Bound to nav a elements
460
+ * @param {Event} e
461
+ */
462
+ _onItemFocus(e){
463
+ if ( this.breakPoints.isMobile() ) return;
464
+ const LI = e.target.parentElement.parentElement;
465
+
466
+ if (LI.hasnav) {
467
+ this.openSubNav(LI.key);
468
+ }
469
+
470
+ if (this.isMegaMenu() && this._megaIsOpen) {
471
+ this._setMegaFocus(LI.key);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * @method _setMegaFocus
477
+ * @private
478
+ * @description Displays custom styling to meganav item when focused to fix bug in sitefarm code.
479
+ * @param {Array} navLocation - Coordinates of the item in the 'navItems' array. i.e. [0, 1, 4].
480
+ */
481
+ _setMegaFocus(navLocation){
482
+ this.navItems.forEach((nav) => nav.megaFocus = false);
483
+ if (
484
+ typeof navLocation !== 'object' ||
485
+ !Array.isArray(navLocation) ||
486
+ navLocation.length < 1
487
+ ) return;
488
+ let navItem = this.getNavItem([navLocation[0]]);
489
+ navItem.megaFocus = true;
490
+ this.requestUpdate();
491
+
492
+ }
493
+
494
+ /**
495
+ * @method _completeMobileTransition
496
+ * @private
497
+ * @description Sets timeout to remove animation styles from mobile transition
498
+ * @param {Object} navItem - Member 'navItems' element property.
499
+ */
500
+ _completeMobileTransition(navItem){
501
+ navItem.timeout = setTimeout(() => {
502
+ navItem.inlineStyles = {};
503
+ navItem.isOpen = !navItem.isOpen;
504
+ navItem.isTransitioning = false;
505
+ this.requestUpdate();
506
+ }, this.animationDuration);
507
+ }
508
+
509
+ /**
510
+ * @method _onItemMouseleave
511
+ * @private
512
+ * @description Bound to nav li items with a subnav
513
+ * @param {Event} e
514
+ */
515
+ _onItemMouseleave(e){
516
+ if ( this.breakPoints.isMobile() || this.isMegaMenu() ) return;
517
+ this.closeSubNav(e.target.key);
518
+ }
519
+
520
+ /**
521
+ * @method _onNavFocusout
522
+ * @private
523
+ * @description Attached to the top-level nav element. Closes subnav if it doesn't contain focused link.
524
+ */
525
+ _onNavFocusout(){
526
+ if ( this.breakPoints.isMobile() ) return;
527
+ if ( this.isMegaMenu() ) {
528
+ if ( this._megaTimeout ) clearTimeout(this._megaTimeout);
529
+ requestAnimationFrame(() => {
530
+ const focusedEle = this.renderRoot.activeElement;
531
+ if ( focusedEle ) return;
532
+ this._megaTimeout = setTimeout(() => {
533
+ this.navItems.forEach((nav) => nav.megaFocus = false);
534
+ this.closeMegaNav();
535
+ }, this.hoverDelay);
536
+ });
537
+
538
+ } else {
539
+ requestAnimationFrame(() => {
540
+ const focusedEle = this.renderRoot.activeElement;
541
+ if ( !focusedEle ) {
542
+ this.closeAllSubNavs();
543
+ return;
544
+ }
545
+
546
+ let ele = focusedEle;
547
+ while (
548
+ ele &&
549
+ ele.tagName !== this.tagName &&
550
+ !Array.isArray(ele.key)
551
+ ){
552
+ ele = ele.parentElement;
553
+ }
554
+ if ( !ele.key ) return;
555
+ let navLocation = [...ele.key];
556
+ let currentIndex = navLocation.pop();
557
+ let navSiblings = navLocation.length == 0 ? this.navItems : this.getNavItem(navLocation).subItems;
558
+ navSiblings.forEach((sibling, i) => {
559
+ if ( i !== currentIndex) {
560
+ sibling.isOpen = false;
561
+ this.closeAllSubNavs(sibling.subItems, false);
562
+ }
563
+ });
564
+ this.requestUpdate();
565
+ });
566
+
567
+ }
568
+
569
+ }
570
+
571
+ /**
572
+ * @method _getItemMobileStyles
573
+ * @private
574
+ * @description Returns inline styles on a nav element (used for mobile transition animation)
575
+ * @param {Array} location - Coordinates of the item in the 'navItems' array. i.e. [0, 1, 4].
576
+ * @returns {Object} - Style map
577
+ */
578
+ _getItemMobileStyles(location) {
579
+ if ( this.breakPoints.isDesktop() ) return {};
580
+ let navItem = this.getNavItem(location);
581
+ if ( !navItem.inlineStyles ) return {};
582
+ return navItem.inlineStyles;
583
+ }
584
+
585
+ }
586
+
587
+ customElements.define('ucdlib-primary-nav', UcdlibPrimaryNav);