@ecl/site-header 5.0.0-alpha.2 → 5.0.0-alpha.21

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 CHANGED
@@ -53,6 +53,9 @@ export class SiteHeader {
53
53
  attachKeyListener = true,
54
54
  attachResizeListener = true,
55
55
  tabletBreakpoint = 768,
56
+ customActionToggleSelector = '[data-ecl-custom-action]',
57
+ customActionOverlaySelector = '[data-ecl-custom-action-overlay]',
58
+ customActionCloseSelector = '[data-ecl-custom-action-close]',
56
59
  } = {},
57
60
  ) {
58
61
  // Check element
@@ -81,6 +84,9 @@ export class SiteHeader {
81
84
  this.attachKeyListener = attachKeyListener;
82
85
  this.attachResizeListener = attachResizeListener;
83
86
  this.tabletBreakpoint = tabletBreakpoint;
87
+ this.customActionToggleSelector = customActionToggleSelector;
88
+ this.customActionOverlaySelector = customActionOverlaySelector;
89
+ this.customActionCloseSelector = customActionCloseSelector;
84
90
 
85
91
  // Private variables
86
92
  this.languageMaxColumnItems = 8;
@@ -98,6 +104,10 @@ export class SiteHeader {
98
104
  this.resizeTimer = null;
99
105
  this.direction = null;
100
106
  this.notificationContainer = null;
107
+ this.customActionToggle = null;
108
+ this.customActionOverlay = null;
109
+ this.customActionClose = null;
110
+ this.customActionFocusTrap = null;
101
111
 
102
112
  // Bind `this` for use in callbacks
103
113
  this.openOverlay = this.openOverlay.bind(this);
@@ -113,6 +123,12 @@ export class SiteHeader {
113
123
  this.handleResize = this.handleResize.bind(this);
114
124
  this.setLanguageListHeight = this.setLanguageListHeight.bind(this);
115
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);
116
132
  }
117
133
 
118
134
  /**
@@ -154,10 +170,12 @@ export class SiteHeader {
154
170
  }
155
171
 
156
172
  // Create focus trap
157
- this.focusTrap = createFocusTrap(this.languageListOverlay, {
158
- onDeactivate: this.closeOverlay,
159
- allowOutsideClick: true,
160
- });
173
+ if (this.languageListOverlay) {
174
+ this.focusTrap = createFocusTrap(this.languageListOverlay, {
175
+ onDeactivate: this.closeOverlay,
176
+ allowOutsideClick: true,
177
+ });
178
+ }
161
179
 
162
180
  if (this.attachClickListener && this.languageLink) {
163
181
  this.languageLink.addEventListener('click', this.toggleOverlay);
@@ -188,6 +206,35 @@ export class SiteHeader {
188
206
  this.loginToggle.addEventListener('click', this.toggleLogin);
189
207
  }
190
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
+
191
238
  // Set ecl initialized attribute
192
239
  this.element.setAttribute('data-ecl-auto-initialized', 'true');
193
240
  ECL.components.set(this.element, this);
@@ -237,6 +284,28 @@ export class SiteHeader {
237
284
  this.loginToggle.removeEventListener('click', this.toggleLogin);
238
285
  }
239
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
+
240
309
  if (this.attachKeyListener) {
241
310
  document.removeEventListener('keyup', this.handleKeyboardGlobal);
242
311
  }
@@ -257,168 +326,148 @@ export class SiteHeader {
257
326
  }
258
327
 
259
328
  /**
260
- * Update display of the modal language list overlay.
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
261
335
  */
262
- updateOverlay() {
263
- // Check number of items and adapt display
264
- let columnsEu = 1;
265
- let columnsNonEu = 1;
266
- if (this.languageListEu) {
267
- // Get all Eu languages
268
- const itemsEu = queryAll(
269
- '.ecl-site-header__language-item',
270
- this.languageListEu,
271
- );
272
-
273
- // Calculate number of columns
274
- columnsEu = Math.ceil(itemsEu.length / this.languageMaxColumnItems);
336
+ updateOverlayPosition(
337
+ overlay,
338
+ toggle,
339
+ arrowCssVar = '--ecl-overlay-arrow-position',
340
+ ) {
341
+ if (!overlay || !toggle || !this.container) return;
275
342
 
276
- // Apply column display
277
- if (columnsEu > 1) {
278
- this.languageListEu.classList.add(
279
- `ecl-site-header__language-category--${columnsEu}-col`,
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,
280
353
  );
354
+ if (columnsEu > 1) {
355
+ this.languageListEu.classList.add(
356
+ `ecl-site-header__language-category--${columnsEu}-col`,
357
+ );
358
+ }
281
359
  }
282
- }
283
- if (this.languageListNonEu) {
284
- // Get all non-Eu languages
285
- const itemsNonEu = queryAll(
286
- '.ecl-site-header__language-item',
287
- this.languageListNonEu,
288
- );
289
-
290
- // Calculate number of columns
291
- columnsNonEu = Math.ceil(itemsNonEu.length / this.languageMaxColumnItems);
292
360
 
293
- // Apply column display
294
- if (columnsNonEu > 1) {
295
- this.languageListNonEu.classList.add(
296
- `ecl-site-header__language-category--${columnsNonEu}-col`,
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,
297
369
  );
370
+ if (columnsNonEu > 1) {
371
+ this.languageListNonEu.classList.add(
372
+ `ecl-site-header__language-category--${columnsNonEu}-col`,
373
+ );
374
+ }
298
375
  }
299
- }
300
-
301
- // Check total width, and change display if needed
302
- if (this.languageListEu) {
303
- this.languageListEu.parentNode.classList.remove(
304
- 'ecl-site-header__language-content--stack',
305
- );
306
- } else if (this.languageListNonEu) {
307
- this.languageListNonEu.parentNode.classList.remove(
308
- 'ecl-site-header__language-content--stack',
309
- );
310
- }
311
- let popoverRect = this.languageListOverlay.getBoundingClientRect();
312
- const containerRect = this.container.getBoundingClientRect();
313
376
 
314
- if (popoverRect.width > containerRect.width) {
315
- // Stack elements
377
+ // Remove stacked classes first
316
378
  if (this.languageListEu) {
317
- this.languageListEu.parentNode.classList.add(
379
+ this.languageListEu.parentNode.classList.remove(
318
380
  'ecl-site-header__language-content--stack',
319
381
  );
320
382
  } else if (this.languageListNonEu) {
321
- this.languageListNonEu.parentNode.classList.add(
383
+ this.languageListNonEu.parentNode.classList.remove(
322
384
  'ecl-site-header__language-content--stack',
323
385
  );
324
386
  }
325
-
326
- // Adapt column display
327
- if (this.languageListNonEu) {
328
- this.languageListNonEu.classList.remove(
329
- `ecl-site-header__language-category--${columnsNonEu}-col`,
330
- );
331
- this.languageListNonEu.classList.add(
332
- `ecl-site-header__language-category--${Math.max(
333
- columnsEu,
334
- columnsNonEu,
335
- )}-col`,
336
- );
337
- }
338
387
  }
339
388
 
340
- // Check available space
341
- this.languageListOverlay.classList.remove(
389
+ // Clear leftover classes/inline styles
390
+ overlay.classList.remove(
342
391
  'ecl-site-header__language-container--push-right',
343
392
  'ecl-site-header__language-container--push-left',
344
- );
345
- this.languageListOverlay.classList.remove(
346
393
  'ecl-site-header__language-container--full',
347
394
  );
348
- this.languageListOverlay.style.removeProperty(
349
- '--ecl-language-arrow-position',
350
- );
351
- this.languageListOverlay.style.removeProperty('right');
352
- this.languageListOverlay.style.removeProperty('left');
395
+ overlay.style.removeProperty(arrowCssVar);
396
+ overlay.style.removeProperty('right');
397
+ overlay.style.removeProperty('left');
353
398
 
354
- popoverRect = this.languageListOverlay.getBoundingClientRect();
399
+ // Calculate bounding rects
400
+ let popoverRect = overlay.getBoundingClientRect();
401
+ const containerRect = this.container.getBoundingClientRect();
355
402
  const screenWidth = window.innerWidth;
356
- const linkRect = this.languageLink.getBoundingClientRect();
357
- // Popover too large
358
- if (this.direction === 'ltr' && popoverRect.right > screenWidth) {
359
- // Push the popover to the right
360
- this.languageListOverlay.classList.add(
361
- 'ecl-site-header__language-container--push-right',
362
- );
363
- this.languageListOverlay.style.setProperty(
364
- 'right',
365
- `calc(-${containerRect.right}px + ${linkRect.right}px)`,
366
- );
367
- // Adapt arrow position
368
- const arrowPosition =
369
- containerRect.right - linkRect.right + linkRect.width / 2;
370
- this.languageListOverlay.style.setProperty(
371
- '--ecl-language-arrow-position',
372
- `calc(${arrowPosition}px - ${this.arrowSize})`,
373
- );
374
- } else if (this.direction === 'rtl' && popoverRect.left < 0) {
375
- this.languageListOverlay.classList.add(
376
- 'ecl-site-header__language-container--push-left',
377
- );
378
- this.languageListOverlay.style.setProperty(
379
- 'left',
380
- `calc(-${linkRect.left}px + ${containerRect.left}px)`,
381
- );
382
- // Adapt arrow position
383
- const arrowPosition =
384
- linkRect.right - containerRect.left - linkRect.width / 2;
385
- this.languageListOverlay.style.setProperty(
386
- '--ecl-language-arrow-position',
387
- `${arrowPosition}px`,
388
- );
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
+ }
389
419
  }
390
420
 
391
- // Mobile popover (full width)
421
+ // Mobile: full width if below tablet breakpoint
392
422
  if (window.innerWidth < this.tabletBreakpoint) {
393
- // Push the popover to the right
394
- this.languageListOverlay.classList.add(
395
- 'ecl-site-header__language-container--full',
396
- );
397
- this.languageListOverlay.style.removeProperty('right');
398
-
399
- // Adapt arrow position
423
+ overlay.classList.add('ecl-site-header__language-container--full');
424
+ // Recompute popoverRect after applying the class
425
+ popoverRect = overlay.getBoundingClientRect();
426
+ // Position arrow
400
427
  const arrowPosition =
401
- popoverRect.right - linkRect.right + linkRect.width / 2;
402
- this.languageListOverlay.style.setProperty(
403
- '--ecl-language-arrow-position',
428
+ popoverRect.right - toggleRect.right + toggleRect.width / 2;
429
+ overlay.style.setProperty(
430
+ arrowCssVar,
404
431
  `calc(${arrowPosition}px - ${this.arrowSize})`,
405
432
  );
433
+ return;
406
434
  }
407
435
 
408
- if (
409
- this.loginBox &&
410
- this.loginBox.classList.contains('ecl-site-header__login-box--active')
411
- ) {
412
- this.setLoginArrow();
413
- }
414
- if (
415
- this.searchForm &&
416
- this.searchForm.classList.contains('ecl-site-header__search--active')
417
- ) {
418
- this.setSearchArrow();
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`);
419
459
  }
420
460
  }
421
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
+
422
471
  /**
423
472
  * Removes the containers of the notification element
424
473
  */
@@ -476,50 +525,13 @@ export class SiteHeader {
476
525
 
477
526
  if (this.languageListOverlay.hasAttribute('hidden')) {
478
527
  this.openOverlay();
479
- this.updateOverlay();
528
+ this.updateOverlayPosition(this.languageListOverlay, this.languageLink);
480
529
  this.focusTrap.activate();
481
530
  } else {
482
531
  this.focusTrap.deactivate();
483
532
  }
484
533
  }
485
534
 
486
- /**
487
- * Trigger events on resize
488
- * Uses a debounce, for performance
489
- */
490
- handleResize() {
491
- if (
492
- !this.languageListOverlay ||
493
- this.languageListOverlay.hasAttribute('hidden')
494
- )
495
- return;
496
- if (
497
- (this.loginBox &&
498
- this.loginBox.classList.contains(
499
- 'ecl-site-header__login-box--active',
500
- )) ||
501
- (this.searchForm &&
502
- this.searchForm.classList.contains('ecl-site-header__search--active'))
503
- ) {
504
- clearTimeout(this.resizeTimer);
505
- this.resizeTimer = setTimeout(() => {
506
- this.updateOverlay();
507
- }, 200);
508
- }
509
- }
510
-
511
- /**
512
- * Handles keyboard events specific to the language list.
513
- *
514
- * @param {Event} e
515
- */
516
- handleKeyboardLanguage(e) {
517
- // Open the menu with space and enter
518
- if (e.keyCode === 32 || e.key === 'Enter') {
519
- this.toggleOverlay(e);
520
- }
521
- }
522
-
523
535
  /**
524
536
  * Toggles the search form.
525
537
  *
@@ -561,7 +573,9 @@ export class SiteHeader {
561
573
  if (loginRect.x === 0) {
562
574
  const loginToggleRect = this.loginToggle.getBoundingClientRect();
563
575
  const arrowPosition =
564
- window.innerWidth - loginToggleRect.right + loginToggleRect.width / 2;
576
+ document.documentElement.clientWidth -
577
+ loginToggleRect.right +
578
+ loginToggleRect.width / 2;
565
579
 
566
580
  this.loginBox.style.setProperty(
567
581
  '--ecl-login-arrow-position',
@@ -575,7 +589,9 @@ export class SiteHeader {
575
589
  if (searchRect.x === 0) {
576
590
  const searchToggleRect = this.searchToggle.getBoundingClientRect();
577
591
  const arrowPosition =
578
- window.innerWidth - searchToggleRect.right + searchToggleRect.width / 2;
592
+ document.documentElement.clientWidth -
593
+ searchToggleRect.right +
594
+ searchToggleRect.width / 2;
579
595
 
580
596
  this.searchForm.style.setProperty(
581
597
  '--ecl-search-arrow-position',
@@ -619,6 +635,18 @@ export class SiteHeader {
619
635
  }
620
636
  }
621
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
+
622
650
  /**
623
651
  * Handles global keyboard events, triggered outside of the site header.
624
652
  *
@@ -633,6 +661,12 @@ export class SiteHeader {
633
661
  if (listExpanded === 'true') {
634
662
  this.toggleOverlay(e);
635
663
  }
664
+ if (
665
+ this.customActionToggle &&
666
+ this.customActionToggle.getAttribute('aria-expanded') === 'true'
667
+ ) {
668
+ this.toggleCustomAction(e);
669
+ }
636
670
  }
637
671
  }
638
672
 
@@ -677,6 +711,126 @@ export class SiteHeader {
677
711
  this.toggleSearch(e);
678
712
  }
679
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
+ }
680
834
  }
681
835
  }
682
836