@ecl/site-header 5.0.0-RC1

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/site-header.js ADDED
@@ -0,0 +1,837 @@
1
+ import { queryOne, queryAll } from '@ecl/dom-utils';
2
+ import { createFocusTrap } from 'focus-trap';
3
+
4
+ /**
5
+ * @param {HTMLElement} element DOM element for component instantiation and scope
6
+ * @param {Object} options
7
+ * @param {String} options.languageLinkSelector
8
+ * @param {String} options.languageListOverlaySelector
9
+ * @param {String} options.languageListEuSelector
10
+ * @param {String} options.languageListNonEuSelector
11
+ * @param {String} options.closeOverlaySelector
12
+ * @param {String} options.searchToggleSelector
13
+ * @param {String} options.searchFormSelector
14
+ * @param {String} options.loginToggleSelector
15
+ * @param {String} options.loginBoxSelector
16
+ * @param {integer} options.tabletBreakpoint
17
+ * @param {Boolean} options.attachClickListener Whether or not to bind click events
18
+ * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
19
+ * @param {Boolean} options.attachResizeListener Whether or not to bind resize events
20
+ */
21
+ export class SiteHeader {
22
+ /**
23
+ * @static
24
+ * Shorthand for instance creation and initialisation.
25
+ *
26
+ * @param {HTMLElement} root DOM element for component instantiation and scope
27
+ *
28
+ * @return {SiteHeader} An instance of SiteHeader.
29
+ */
30
+ static autoInit(root, { SITE_HEADER_CORE: defaultOptions = {} } = {}) {
31
+ const siteHeader = new SiteHeader(root, defaultOptions);
32
+ siteHeader.init();
33
+ root.ECLSiteHeader = siteHeader;
34
+ return siteHeader;
35
+ }
36
+
37
+ constructor(
38
+ element,
39
+ {
40
+ containerSelector = '[data-ecl-site-header-top]',
41
+ languageLinkSelector = '[data-ecl-language-selector]',
42
+ languageListOverlaySelector = '[data-ecl-language-list-overlay]',
43
+ languageListEuSelector = '[data-ecl-language-list-eu]',
44
+ languageListNonEuSelector = '[data-ecl-language-list-non-eu]',
45
+ languageListContentSelector = '[data-ecl-language-list-content]',
46
+ closeOverlaySelector = '[data-ecl-language-list-close]',
47
+ searchToggleSelector = '[data-ecl-search-toggle]',
48
+ searchFormSelector = '[data-ecl-search-form]',
49
+ loginToggleSelector = '[data-ecl-login-toggle]',
50
+ loginBoxSelector = '[data-ecl-login-box]',
51
+ notificationSelector = '[data-ecl-site-header-notification]',
52
+ attachClickListener = true,
53
+ attachKeyListener = true,
54
+ attachResizeListener = true,
55
+ tabletBreakpoint = 768,
56
+ customActionToggleSelector = '[data-ecl-custom-action]',
57
+ customActionOverlaySelector = '[data-ecl-custom-action-overlay]',
58
+ customActionCloseSelector = '[data-ecl-custom-action-close]',
59
+ } = {},
60
+ ) {
61
+ // Check element
62
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
63
+ throw new TypeError(
64
+ 'DOM element should be given to initialize this widget.',
65
+ );
66
+ }
67
+
68
+ this.element = element;
69
+
70
+ // Options
71
+ this.containerSelector = containerSelector;
72
+ this.languageLinkSelector = languageLinkSelector;
73
+ this.languageListOverlaySelector = languageListOverlaySelector;
74
+ this.languageListEuSelector = languageListEuSelector;
75
+ this.languageListNonEuSelector = languageListNonEuSelector;
76
+ this.languageListContentSelector = languageListContentSelector;
77
+ this.closeOverlaySelector = closeOverlaySelector;
78
+ this.searchToggleSelector = searchToggleSelector;
79
+ this.searchFormSelector = searchFormSelector;
80
+ this.loginToggleSelector = loginToggleSelector;
81
+ this.notificationSelector = notificationSelector;
82
+ this.loginBoxSelector = loginBoxSelector;
83
+ this.attachClickListener = attachClickListener;
84
+ this.attachKeyListener = attachKeyListener;
85
+ this.attachResizeListener = attachResizeListener;
86
+ this.tabletBreakpoint = tabletBreakpoint;
87
+ this.customActionToggleSelector = customActionToggleSelector;
88
+ this.customActionOverlaySelector = customActionOverlaySelector;
89
+ this.customActionCloseSelector = customActionCloseSelector;
90
+
91
+ // Private variables
92
+ this.languageMaxColumnItems = 8;
93
+ this.languageLink = null;
94
+ this.languageListOverlay = null;
95
+ this.languageListEu = null;
96
+ this.languageListNonEu = null;
97
+ this.languageListContent = null;
98
+ this.close = null;
99
+ this.focusTrap = null;
100
+ this.searchToggle = null;
101
+ this.searchForm = null;
102
+ this.loginToggle = null;
103
+ this.loginBox = null;
104
+ this.resizeTimer = null;
105
+ this.direction = null;
106
+ this.notificationContainer = null;
107
+ this.customActionToggle = null;
108
+ this.customActionOverlay = null;
109
+ this.customActionClose = null;
110
+ this.customActionFocusTrap = null;
111
+
112
+ // Bind `this` for use in callbacks
113
+ this.openOverlay = this.openOverlay.bind(this);
114
+ this.closeOverlay = this.closeOverlay.bind(this);
115
+ this.toggleOverlay = this.toggleOverlay.bind(this);
116
+ this.toggleSearch = this.toggleSearch.bind(this);
117
+ this.toggleLogin = this.toggleLogin.bind(this);
118
+ this.setLoginArrow = this.setLoginArrow.bind(this);
119
+ this.setSearchArrow = this.setSearchArrow.bind(this);
120
+ this.handleKeyboardLanguage = this.handleKeyboardLanguage.bind(this);
121
+ this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
122
+ this.handleClickGlobal = this.handleClickGlobal.bind(this);
123
+ this.handleResize = this.handleResize.bind(this);
124
+ this.setLanguageListHeight = this.setLanguageListHeight.bind(this);
125
+ this.handleNotificationClose = this.handleNotificationClose.bind(this);
126
+
127
+ this.openCustomAction = this.openCustomAction.bind(this);
128
+ this.closeCustomAction = this.closeCustomAction.bind(this);
129
+ this.toggleCustomAction = this.toggleCustomAction.bind(this);
130
+ this.handleKeyboardCustomAction =
131
+ this.handleKeyboardCustomAction.bind(this);
132
+ }
133
+
134
+ /**
135
+ * Initialise component.
136
+ */
137
+ init() {
138
+ if (!ECL) {
139
+ throw new TypeError('Called init but ECL is not present');
140
+ }
141
+ ECL.components = ECL.components || new Map();
142
+ this.arrowSize = '0.5rem';
143
+ // Bind global events
144
+ if (this.attachKeyListener) {
145
+ document.addEventListener('keyup', this.handleKeyboardGlobal);
146
+ }
147
+ if (this.attachClickListener) {
148
+ document.addEventListener('click', this.handleClickGlobal);
149
+ }
150
+ if (this.attachResizeListener) {
151
+ window.addEventListener('resize', this.handleResize);
152
+ }
153
+
154
+ // Site header elements
155
+ this.container = queryOne(this.containerSelector);
156
+
157
+ // Language list management
158
+ this.languageLink = queryOne(this.languageLinkSelector);
159
+ this.languageListOverlay = queryOne(this.languageListOverlaySelector);
160
+ this.languageListEu = queryOne(this.languageListEuSelector);
161
+ this.languageListNonEu = queryOne(this.languageListNonEuSelector);
162
+ this.languageListContent = queryOne(this.languageListContentSelector);
163
+ this.close = queryOne(this.closeOverlaySelector);
164
+ this.notification = queryOne(this.notificationSelector);
165
+
166
+ // direction
167
+ this.direction = getComputedStyle(this.element).direction;
168
+ if (this.direction === 'rtl') {
169
+ this.element.classList.add('ecl-site-header--rtl');
170
+ }
171
+
172
+ // Create focus trap
173
+ if (this.languageListOverlay) {
174
+ this.focusTrap = createFocusTrap(this.languageListOverlay, {
175
+ onDeactivate: this.closeOverlay,
176
+ allowOutsideClick: true,
177
+ });
178
+ }
179
+
180
+ if (this.attachClickListener && this.languageLink) {
181
+ this.languageLink.addEventListener('click', this.toggleOverlay);
182
+ }
183
+ if (this.attachClickListener && this.close) {
184
+ this.close.addEventListener('click', this.toggleOverlay);
185
+ }
186
+ if (this.attachKeyListener && this.languageLink) {
187
+ this.languageLink.addEventListener(
188
+ 'keydown',
189
+ this.handleKeyboardLanguage,
190
+ );
191
+ }
192
+
193
+ // Search form management
194
+ this.searchToggle = queryOne(this.searchToggleSelector);
195
+ this.searchForm = queryOne(this.searchFormSelector);
196
+
197
+ if (this.attachClickListener && this.searchToggle) {
198
+ this.searchToggle.addEventListener('click', this.toggleSearch);
199
+ }
200
+
201
+ // Login management
202
+ this.loginToggle = queryOne(this.loginToggleSelector);
203
+ this.loginBox = queryOne(this.loginBoxSelector);
204
+
205
+ if (this.attachClickListener && this.loginToggle) {
206
+ this.loginToggle.addEventListener('click', this.toggleLogin);
207
+ }
208
+
209
+ // Custom action management
210
+ this.customActionToggle = queryOne(this.customActionToggleSelector);
211
+ this.customActionOverlay = queryOne(this.customActionOverlaySelector);
212
+ this.customActionClose = queryOne(this.customActionCloseSelector);
213
+
214
+ if (this.customActionOverlay) {
215
+ // Create a separate focus trap for the custom action overlay
216
+ this.customActionFocusTrap = createFocusTrap(this.customActionOverlay, {
217
+ onDeactivate: this.closeCustomAction,
218
+ allowOutsideClick: true,
219
+ });
220
+ }
221
+
222
+ if (this.attachClickListener && this.customActionToggle) {
223
+ this.customActionToggle.addEventListener(
224
+ 'click',
225
+ this.toggleCustomAction,
226
+ );
227
+ }
228
+ if (this.attachClickListener && this.customActionClose) {
229
+ this.customActionClose.addEventListener('click', this.toggleCustomAction);
230
+ }
231
+ if (this.attachKeyListener && this.customActionToggle) {
232
+ this.customActionToggle.addEventListener(
233
+ 'keydown',
234
+ this.handleKeyboardCustomAction,
235
+ );
236
+ }
237
+
238
+ // Set ecl initialized attribute
239
+ this.element.setAttribute('data-ecl-auto-initialized', 'true');
240
+ ECL.components.set(this.element, this);
241
+
242
+ if (this.notification) {
243
+ this.notificationContainer = this.notification.closest(
244
+ '.ecl-site-header__notification',
245
+ );
246
+
247
+ setTimeout(() => {
248
+ const eclNotification = ECL.components.get(this.notification);
249
+
250
+ if (eclNotification) {
251
+ eclNotification.on('onClose', this.handleNotificationClose);
252
+ }
253
+ }, 0);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Destroy component.
259
+ */
260
+ destroy() {
261
+ if (this.attachClickListener && this.languageLink) {
262
+ this.languageLink.removeEventListener('click', this.toggleOverlay);
263
+ }
264
+ if (this.focusTrap) {
265
+ this.focusTrap.deactivate();
266
+ }
267
+
268
+ if (this.attachKeyListener && this.languageLink) {
269
+ this.languageLink.removeEventListener(
270
+ 'keydown',
271
+ this.handleKeyboardLanguage,
272
+ );
273
+ }
274
+
275
+ if (this.attachClickListener && this.close) {
276
+ this.close.removeEventListener('click', this.toggleOverlay);
277
+ }
278
+
279
+ if (this.attachClickListener && this.searchToggle) {
280
+ this.searchToggle.removeEventListener('click', this.toggleSearch);
281
+ }
282
+
283
+ if (this.attachClickListener && this.loginToggle) {
284
+ this.loginToggle.removeEventListener('click', this.toggleLogin);
285
+ }
286
+
287
+ if (this.attachClickListener && this.customActionToggle) {
288
+ this.customActionToggle.removeEventListener(
289
+ 'click',
290
+ this.toggleCustomAction,
291
+ );
292
+ }
293
+ if (this.customActionFocusTrap) {
294
+ this.customActionFocusTrap.deactivate();
295
+ }
296
+ if (this.attachKeyListener && this.customActionToggle) {
297
+ this.customActionToggle.removeEventListener(
298
+ 'keydown',
299
+ this.handleKeyboardCustomAction,
300
+ );
301
+ }
302
+ if (this.attachClickListener && this.customActionClose) {
303
+ this.customActionClose.removeEventListener(
304
+ 'click',
305
+ this.toggleCustomAction,
306
+ );
307
+ }
308
+
309
+ if (this.attachKeyListener) {
310
+ document.removeEventListener('keyup', this.handleKeyboardGlobal);
311
+ }
312
+
313
+ if (this.attachClickListener) {
314
+ document.removeEventListener('click', this.handleClickGlobal);
315
+ }
316
+
317
+ if (this.attachResizeListener) {
318
+ window.removeEventListener('resize', this.handleResize);
319
+ }
320
+
321
+ if (this.element) {
322
+ this.element.removeAttribute('data-ecl-auto-initialized');
323
+ this.element.classList.remove('ecl-site-header--rtl');
324
+ ECL.components.delete(this.element);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Method repositions a popover overlay in the viewport.
330
+ * It also (optionally) handles language-list–specific logic such as columns.
331
+ *
332
+ * @param {HTMLElement} overlay The overlay element to be positioned
333
+ * @param {HTMLElement} toggle The toggle button/link that opens the overlay
334
+ * @param {String} arrowCssVar The CSS variable name for arrow positioning
335
+ */
336
+ updateOverlayPosition(
337
+ overlay,
338
+ toggle,
339
+ arrowCssVar = '--ecl-overlay-arrow-position',
340
+ ) {
341
+ if (!overlay || !toggle || !this.container) return;
342
+
343
+ // If this overlay is the language overlay, handle columns (EU vs Non-EU items)
344
+ if (overlay === this.languageListOverlay) {
345
+ // Calculate columns for the EU language list
346
+ if (this.languageListEu) {
347
+ const itemsEu = queryAll(
348
+ '.ecl-site-header__language-item',
349
+ this.languageListEu,
350
+ );
351
+ const columnsEu = Math.ceil(
352
+ itemsEu.length / this.languageMaxColumnItems,
353
+ );
354
+ if (columnsEu > 1) {
355
+ this.languageListEu.classList.add(
356
+ `ecl-site-header__language-category--${columnsEu}-col`,
357
+ );
358
+ }
359
+ }
360
+
361
+ // Calculate columns for the Non-EU language list
362
+ if (this.languageListNonEu) {
363
+ const itemsNonEu = queryAll(
364
+ '.ecl-site-header__language-item',
365
+ this.languageListNonEu,
366
+ );
367
+ const columnsNonEu = Math.ceil(
368
+ itemsNonEu.length / this.languageMaxColumnItems,
369
+ );
370
+ if (columnsNonEu > 1) {
371
+ this.languageListNonEu.classList.add(
372
+ `ecl-site-header__language-category--${columnsNonEu}-col`,
373
+ );
374
+ }
375
+ }
376
+
377
+ // Remove stacked classes first
378
+ if (this.languageListEu) {
379
+ this.languageListEu.parentNode.classList.remove(
380
+ 'ecl-site-header__language-content--stack',
381
+ );
382
+ } else if (this.languageListNonEu) {
383
+ this.languageListNonEu.parentNode.classList.remove(
384
+ 'ecl-site-header__language-content--stack',
385
+ );
386
+ }
387
+ }
388
+
389
+ // Clear leftover classes/inline styles
390
+ overlay.classList.remove(
391
+ 'ecl-site-header__language-container--push-right',
392
+ 'ecl-site-header__language-container--push-left',
393
+ 'ecl-site-header__language-container--full',
394
+ );
395
+ overlay.style.removeProperty(arrowCssVar);
396
+ overlay.style.removeProperty('right');
397
+ overlay.style.removeProperty('left');
398
+
399
+ // Calculate bounding rects
400
+ let popoverRect = overlay.getBoundingClientRect();
401
+ const containerRect = this.container.getBoundingClientRect();
402
+ const screenWidth = window.innerWidth;
403
+ const toggleRect = toggle.getBoundingClientRect();
404
+
405
+ // If this is the language overlay, handle “too wide” scenario
406
+ if (overlay === this.languageListOverlay) {
407
+ if (popoverRect.width > containerRect.width) {
408
+ // Stack elements
409
+ if (this.languageListEu) {
410
+ this.languageListEu.parentNode.classList.add(
411
+ 'ecl-site-header__language-content--stack',
412
+ );
413
+ } else if (this.languageListNonEu) {
414
+ this.languageListNonEu.parentNode.classList.add(
415
+ 'ecl-site-header__language-content--stack',
416
+ );
417
+ }
418
+ }
419
+ }
420
+
421
+ // Mobile: full width if below tablet breakpoint
422
+ if (window.innerWidth < this.tabletBreakpoint) {
423
+ overlay.classList.add('ecl-site-header__language-container--full');
424
+ // Recompute popoverRect after applying the class
425
+ popoverRect = overlay.getBoundingClientRect();
426
+ // Position arrow
427
+ const arrowPosition =
428
+ popoverRect.right - toggleRect.right + toggleRect.width / 2;
429
+ overlay.style.setProperty(
430
+ arrowCssVar,
431
+ `calc(${arrowPosition}px - ${this.arrowSize})`,
432
+ );
433
+ return;
434
+ }
435
+
436
+ // If popover extends beyond right edge in LTR
437
+ if (this.direction === 'ltr' && popoverRect.right > screenWidth) {
438
+ overlay.classList.add('ecl-site-header__language-container--push-right');
439
+ overlay.style.setProperty(
440
+ 'right',
441
+ `calc(-${containerRect.right}px + ${toggleRect.right}px)`,
442
+ );
443
+ const arrowPos =
444
+ containerRect.right - toggleRect.right + toggleRect.width / 2;
445
+ overlay.style.setProperty(
446
+ arrowCssVar,
447
+ `calc(${arrowPos}px - ${this.arrowSize})`,
448
+ );
449
+ } else if (this.direction === 'rtl' && popoverRect.left < 0) {
450
+ // If popover extends beyond left edge in RTL
451
+ overlay.classList.add('ecl-site-header__language-container--push-left');
452
+ overlay.style.setProperty(
453
+ 'left',
454
+ `calc(-${toggleRect.left}px + ${containerRect.left}px)`,
455
+ );
456
+ const arrowPos =
457
+ toggleRect.right - containerRect.left - toggleRect.width / 2;
458
+ overlay.style.setProperty(arrowCssVar, `${arrowPos}px`);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Wrapper: Update display of the modal language list overlay
464
+ * (Calls the new `updateOverlayPosition` for the language popover).
465
+ */
466
+ updateOverlay() {
467
+ if (!this.languageListOverlay || !this.languageLink) return;
468
+ this.updateOverlayPosition(this.languageListOverlay, this.languageLink);
469
+ }
470
+
471
+ /**
472
+ * Removes the containers of the notification element
473
+ */
474
+ handleNotificationClose() {
475
+ if (this.notificationContainer) {
476
+ this.notificationContainer.remove();
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Set a max height for the language list content
482
+ */
483
+ setLanguageListHeight() {
484
+ const viewportHeight = window.innerHeight;
485
+
486
+ if (this.languageListContent) {
487
+ const listTop = this.languageListContent.getBoundingClientRect().top;
488
+
489
+ const availableSpace = viewportHeight - listTop;
490
+ if (availableSpace > 0) {
491
+ this.languageListContent.style.maxHeight = `${availableSpace}px`;
492
+ }
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Shows the modal language list overlay.
498
+ */
499
+ openOverlay() {
500
+ // Display language list
501
+ this.languageListOverlay.hidden = false;
502
+ this.languageListOverlay.setAttribute('aria-modal', 'true');
503
+ this.languageLink.setAttribute('aria-expanded', 'true');
504
+ this.setLanguageListHeight();
505
+ }
506
+
507
+ /**
508
+ * Hides the modal language list overlay.
509
+ */
510
+ closeOverlay() {
511
+ this.languageListOverlay.hidden = true;
512
+ this.languageListOverlay.removeAttribute('aria-modal');
513
+ this.languageLink.setAttribute('aria-expanded', 'false');
514
+ }
515
+
516
+ /**
517
+ * Toggles the modal language list overlay.
518
+ *
519
+ * @param {Event} e
520
+ */
521
+ toggleOverlay(e) {
522
+ if (!this.languageListOverlay || !this.focusTrap) return;
523
+
524
+ e.preventDefault();
525
+
526
+ if (this.languageListOverlay.hasAttribute('hidden')) {
527
+ this.openOverlay();
528
+ this.updateOverlayPosition(this.languageListOverlay, this.languageLink);
529
+ this.focusTrap.activate();
530
+ } else {
531
+ this.focusTrap.deactivate();
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Toggles the search form.
537
+ *
538
+ * @param {Event} e
539
+ */
540
+ toggleSearch(e) {
541
+ if (!this.searchForm) return;
542
+
543
+ e.preventDefault();
544
+
545
+ // Get current status
546
+ const isExpanded =
547
+ this.searchToggle.getAttribute('aria-expanded') === 'true';
548
+
549
+ // Close other boxes
550
+ if (
551
+ this.loginToggle &&
552
+ this.loginToggle.getAttribute('aria-expanded') === 'true'
553
+ ) {
554
+ this.toggleLogin(e);
555
+ }
556
+
557
+ // Toggle the search form
558
+ this.searchToggle.setAttribute(
559
+ 'aria-expanded',
560
+ isExpanded ? 'false' : 'true',
561
+ );
562
+
563
+ if (!isExpanded) {
564
+ this.searchForm.classList.add('ecl-site-header__search--active');
565
+ this.setSearchArrow();
566
+ } else {
567
+ this.searchForm.classList.remove('ecl-site-header__search--active');
568
+ }
569
+ }
570
+
571
+ setLoginArrow() {
572
+ const loginRect = this.loginBox.getBoundingClientRect();
573
+ if (loginRect.x === 0) {
574
+ const loginToggleRect = this.loginToggle.getBoundingClientRect();
575
+ const arrowPosition =
576
+ document.documentElement.clientWidth -
577
+ loginToggleRect.right +
578
+ loginToggleRect.width / 2;
579
+
580
+ this.loginBox.style.setProperty(
581
+ '--ecl-login-arrow-position',
582
+ `calc(${arrowPosition}px - ${this.arrowSize})`,
583
+ );
584
+ }
585
+ }
586
+
587
+ setSearchArrow() {
588
+ const searchRect = this.searchForm.getBoundingClientRect();
589
+ if (searchRect.x === 0) {
590
+ const searchToggleRect = this.searchToggle.getBoundingClientRect();
591
+ const arrowPosition =
592
+ document.documentElement.clientWidth -
593
+ searchToggleRect.right +
594
+ searchToggleRect.width / 2;
595
+
596
+ this.searchForm.style.setProperty(
597
+ '--ecl-search-arrow-position',
598
+ `calc(${arrowPosition}px - ${this.arrowSize})`,
599
+ );
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Toggles the login form.
605
+ *
606
+ * @param {Event} e
607
+ */
608
+ toggleLogin(e) {
609
+ if (!this.loginBox) return;
610
+
611
+ e.preventDefault();
612
+
613
+ // Get current status
614
+ const isExpanded =
615
+ this.loginToggle.getAttribute('aria-expanded') === 'true';
616
+
617
+ // Close other boxes
618
+ if (
619
+ this.searchToggle &&
620
+ this.searchToggle.getAttribute('aria-expanded') === 'true'
621
+ ) {
622
+ this.toggleSearch(e);
623
+ }
624
+
625
+ // Toggle the login box
626
+ this.loginToggle.setAttribute(
627
+ 'aria-expanded',
628
+ isExpanded ? 'false' : 'true',
629
+ );
630
+ if (!isExpanded) {
631
+ this.loginBox.classList.add('ecl-site-header__login-box--active');
632
+ this.setLoginArrow();
633
+ } else {
634
+ this.loginBox.classList.remove('ecl-site-header__login-box--active');
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Handles keyboard events specific to the language list.
640
+ *
641
+ * @param {Event} e
642
+ */
643
+ handleKeyboardLanguage(e) {
644
+ // Open the menu with space and enter
645
+ if (e.keyCode === 32 || e.key === 'Enter') {
646
+ this.toggleOverlay(e);
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Handles global keyboard events, triggered outside of the site header.
652
+ *
653
+ * @param {Event} e
654
+ */
655
+ handleKeyboardGlobal(e) {
656
+ if (!this.languageLink) return;
657
+ const listExpanded = this.languageLink.getAttribute('aria-expanded');
658
+
659
+ // Detect press on Escape
660
+ if (e.key === 'Escape' || e.key === 'Esc') {
661
+ if (listExpanded === 'true') {
662
+ this.toggleOverlay(e);
663
+ }
664
+ if (
665
+ this.customActionToggle &&
666
+ this.customActionToggle.getAttribute('aria-expanded') === 'true'
667
+ ) {
668
+ this.toggleCustomAction(e);
669
+ }
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Handles global click events, triggered outside of the site header.
675
+ *
676
+ * @param {Event} e
677
+ */
678
+ handleClickGlobal(e) {
679
+ if (!this.languageLink && !this.searchToggle && !this.loginToggle) return;
680
+ const listExpanded =
681
+ this.languageLink && this.languageLink.getAttribute('aria-expanded');
682
+ const loginExpanded =
683
+ this.loginToggle &&
684
+ this.loginToggle.getAttribute('aria-expanded') === 'true';
685
+ const searchExpanded =
686
+ this.searchToggle &&
687
+ this.searchToggle.getAttribute('aria-expanded') === 'true';
688
+ // Check if the language list is open
689
+ if (listExpanded === 'true') {
690
+ // Check if the click occured in the language popover
691
+ if (
692
+ !this.languageListOverlay.contains(e.target) &&
693
+ !this.languageLink.contains(e.target)
694
+ ) {
695
+ this.toggleOverlay(e);
696
+ }
697
+ }
698
+ if (loginExpanded) {
699
+ if (
700
+ !this.loginBox.contains(e.target) &&
701
+ !this.loginToggle.contains(e.target)
702
+ ) {
703
+ this.toggleLogin(e);
704
+ }
705
+ }
706
+ if (searchExpanded) {
707
+ if (
708
+ !this.searchForm.contains(e.target) &&
709
+ !this.searchToggle.contains(e.target)
710
+ ) {
711
+ this.toggleSearch(e);
712
+ }
713
+ }
714
+
715
+ // Custom action
716
+ const customActionExpanded =
717
+ this.customActionToggle &&
718
+ this.customActionToggle.getAttribute('aria-expanded') === 'true';
719
+ if (customActionExpanded) {
720
+ if (
721
+ !this.customActionOverlay.contains(e.target) &&
722
+ !this.customActionToggle.contains(e.target)
723
+ ) {
724
+ this.toggleCustomAction(e);
725
+ }
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Trigger events on resize
731
+ * Uses a debounce, for performance
732
+ */
733
+ handleResize() {
734
+ if (this.resizeTimer) {
735
+ clearTimeout(this.resizeTimer);
736
+ }
737
+ this.resizeTimer = setTimeout(() => {
738
+ // If language overlay is open, reposition
739
+ if (
740
+ this.languageListOverlay &&
741
+ !this.languageListOverlay.hasAttribute('hidden')
742
+ ) {
743
+ this.updateOverlayPosition(this.languageListOverlay, this.languageLink);
744
+ }
745
+
746
+ // If custom action overlay is open, reposition
747
+ if (
748
+ this.customActionOverlay &&
749
+ !this.customActionOverlay.hasAttribute('hidden')
750
+ ) {
751
+ this.updateOverlayPosition(
752
+ this.customActionOverlay,
753
+ this.customActionToggle,
754
+ '--ecl-overlay-arrow-position',
755
+ );
756
+ }
757
+
758
+ // If the login box is open, re-position arrow
759
+ if (
760
+ this.loginBox &&
761
+ this.loginBox.classList.contains('ecl-site-header__login-box--active')
762
+ ) {
763
+ this.setLoginArrow();
764
+ }
765
+
766
+ // If the search box is open, re-position arrow
767
+ if (
768
+ this.searchForm &&
769
+ this.searchForm.classList.contains('ecl-site-header__search--active')
770
+ ) {
771
+ this.setSearchArrow();
772
+ }
773
+ }, 200);
774
+ }
775
+
776
+ /**
777
+ * Shows the custom action overlay.
778
+ */
779
+ openCustomAction() {
780
+ if (!this.customActionOverlay) return;
781
+ this.customActionOverlay.hidden = false;
782
+ this.customActionOverlay.setAttribute('aria-modal', 'true');
783
+ this.customActionToggle.setAttribute('aria-expanded', 'true');
784
+ }
785
+
786
+ /**
787
+ * Hides the custom action overlay.
788
+ */
789
+ closeCustomAction() {
790
+ if (!this.customActionOverlay) return;
791
+ this.customActionOverlay.hidden = true;
792
+ this.customActionOverlay.removeAttribute('aria-modal');
793
+ this.customActionToggle.setAttribute('aria-expanded', 'false');
794
+ }
795
+
796
+ /**
797
+ * Toggles the custom action overlay.
798
+ *
799
+ * @param {Event} e
800
+ */
801
+ toggleCustomAction(e) {
802
+ if (!this.customActionOverlay || !this.customActionFocusTrap) return;
803
+
804
+ e.preventDefault();
805
+
806
+ // Check current state
807
+ const isHidden = this.customActionOverlay.hasAttribute('hidden');
808
+
809
+ if (isHidden) {
810
+ this.openCustomAction();
811
+ // Reuse the same overlay positioning logic,
812
+ // but use a different CSS arrow var for custom action if you like.
813
+ this.updateOverlayPosition(
814
+ this.customActionOverlay,
815
+ this.customActionToggle,
816
+ '--ecl-overlay-arrow-position',
817
+ );
818
+ this.customActionFocusTrap.activate();
819
+ } else {
820
+ this.customActionFocusTrap.deactivate();
821
+ }
822
+ }
823
+
824
+ /**
825
+ * Handles keyboard events specific to the custom action toggle.
826
+ *
827
+ * @param {Event} e
828
+ */
829
+ handleKeyboardCustomAction(e) {
830
+ // Open the custom action with space and enter
831
+ if (e.keyCode === 32 || e.key === 'Enter') {
832
+ this.toggleCustomAction(e);
833
+ }
834
+ }
835
+ }
836
+
837
+ export default SiteHeader;