import { queryOne, queryAll } from '@ecl/dom-utils';
import { createFocusTrap } from 'focus-trap';
/**
* @param {HTMLElement} element DOM element for component instantiation and scope
* @param {Object} options
* @param {String} options.languageLinkSelector
* @param {String} options.languageListOverlaySelector
* @param {String} options.languageListEuSelector
* @param {String} options.languageListNonEuSelector
* @param {String} options.closeOverlaySelector
* @param {String} options.searchToggleSelector
* @param {String} options.searchFormSelector
* @param {String} options.loginToggleSelector
* @param {String} options.loginBoxSelector
* @param {integer} options.tabletBreakpoint
* @param {Boolean} options.attachClickListener Whether or not to bind click events
* @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
* @param {Boolean} options.attachResizeListener Whether or not to bind resize events
*/
export class SiteHeader {
/**
* @static
* Shorthand for instance creation and initialisation.
*
* @param {HTMLElement} root DOM element for component instantiation and scope
*
* @return {SiteHeader} An instance of SiteHeader.
*/
static autoInit(root, { SITE_HEADER_CORE: defaultOptions = {} } = {}) {
const siteHeader = new SiteHeader(root, defaultOptions);
siteHeader.init();
root.ECLSiteHeader = siteHeader;
return siteHeader;
}
constructor(
element,
{
containerSelector = '[data-ecl-site-header-top]',
languageLinkSelector = '[data-ecl-language-selector]',
languageListOverlaySelector = '[data-ecl-language-list-overlay]',
languageListEuSelector = '[data-ecl-language-list-eu]',
languageListNonEuSelector = '[data-ecl-language-list-non-eu]',
languageListContentSelector = '[data-ecl-language-list-content]',
closeOverlaySelector = '[data-ecl-language-list-close]',
searchToggleSelector = '[data-ecl-search-toggle]',
searchFormSelector = '[data-ecl-search-form]',
loginToggleSelector = '[data-ecl-login-toggle]',
loginBoxSelector = '[data-ecl-login-box]',
notificationSelector = '[data-ecl-site-header-notification]',
attachClickListener = true,
attachKeyListener = true,
attachResizeListener = true,
tabletBreakpoint = 768,
customActionToggleSelector = '[data-ecl-custom-action]',
customActionOverlaySelector = '[data-ecl-custom-action-overlay]',
customActionCloseSelector = '[data-ecl-custom-action-close]',
} = {},
) {
// Check element
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
throw new TypeError(
'DOM element should be given to initialize this widget.',
);
}
this.element = element;
// Options
this.containerSelector = containerSelector;
this.languageLinkSelector = languageLinkSelector;
this.languageListOverlaySelector = languageListOverlaySelector;
this.languageListEuSelector = languageListEuSelector;
this.languageListNonEuSelector = languageListNonEuSelector;
this.languageListContentSelector = languageListContentSelector;
this.closeOverlaySelector = closeOverlaySelector;
this.searchToggleSelector = searchToggleSelector;
this.searchFormSelector = searchFormSelector;
this.loginToggleSelector = loginToggleSelector;
this.notificationSelector = notificationSelector;
this.loginBoxSelector = loginBoxSelector;
this.attachClickListener = attachClickListener;
this.attachKeyListener = attachKeyListener;
this.attachResizeListener = attachResizeListener;
this.tabletBreakpoint = tabletBreakpoint;
this.customActionToggleSelector = customActionToggleSelector;
this.customActionOverlaySelector = customActionOverlaySelector;
this.customActionCloseSelector = customActionCloseSelector;
// Private variables
this.languageMaxColumnItems = 8;
this.languageLink = null;
this.languageListOverlay = null;
this.languageListEu = null;
this.languageListNonEu = null;
this.languageListContent = null;
this.close = null;
this.focusTrap = null;
this.searchToggle = null;
this.searchForm = null;
this.loginToggle = null;
this.loginBox = null;
this.resizeTimer = null;
this.direction = null;
this.notificationContainer = null;
this.customActionToggle = null;
this.customActionOverlay = null;
this.customActionClose = null;
this.customActionFocusTrap = null;
// Bind `this` for use in callbacks
this.openOverlay = this.openOverlay.bind(this);
this.closeOverlay = this.closeOverlay.bind(this);
this.toggleOverlay = this.toggleOverlay.bind(this);
this.toggleSearch = this.toggleSearch.bind(this);
this.toggleLogin = this.toggleLogin.bind(this);
this.setLoginArrow = this.setLoginArrow.bind(this);
this.setSearchArrow = this.setSearchArrow.bind(this);
this.handleKeyboardLanguage = this.handleKeyboardLanguage.bind(this);
this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
this.handleClickGlobal = this.handleClickGlobal.bind(this);
this.handleResize = this.handleResize.bind(this);
this.setLanguageListHeight = this.setLanguageListHeight.bind(this);
this.handleNotificationClose = this.handleNotificationClose.bind(this);
this.openCustomAction = this.openCustomAction.bind(this);
this.closeCustomAction = this.closeCustomAction.bind(this);
this.toggleCustomAction = this.toggleCustomAction.bind(this);
this.handleKeyboardCustomAction =
this.handleKeyboardCustomAction.bind(this);
}
/**
* Initialise component.
*/
init() {
if (!ECL) {
throw new TypeError('Called init but ECL is not present');
}
ECL.components = ECL.components || new Map();
this.arrowSize = '0.5rem';
// Bind global events
if (this.attachKeyListener) {
document.addEventListener('keyup', this.handleKeyboardGlobal);
}
if (this.attachClickListener) {
document.addEventListener('click', this.handleClickGlobal);
}
if (this.attachResizeListener) {
window.addEventListener('resize', this.handleResize);
}
// Site header elements
this.container = queryOne(this.containerSelector);
// Language list management
this.languageLink = queryOne(this.languageLinkSelector);
this.languageListOverlay = queryOne(this.languageListOverlaySelector);
this.languageListEu = queryOne(this.languageListEuSelector);
this.languageListNonEu = queryOne(this.languageListNonEuSelector);
this.languageListContent = queryOne(this.languageListContentSelector);
this.close = queryOne(this.closeOverlaySelector);
this.notification = queryOne(this.notificationSelector);
// direction
this.direction = getComputedStyle(this.element).direction;
if (this.direction === 'rtl') {
this.element.classList.add('ecl-site-header--rtl');
}
// Create focus trap
if (this.languageListOverlay) {
this.focusTrap = createFocusTrap(this.languageListOverlay, {
onDeactivate: this.closeOverlay,
allowOutsideClick: true,
});
}
if (this.attachClickListener && this.languageLink) {
this.languageLink.addEventListener('click', this.toggleOverlay);
}
if (this.attachClickListener && this.close) {
this.close.addEventListener('click', this.toggleOverlay);
}
if (this.attachKeyListener && this.languageLink) {
this.languageLink.addEventListener(
'keydown',
this.handleKeyboardLanguage,
);
}
// Search form management
this.searchToggle = queryOne(this.searchToggleSelector);
this.searchForm = queryOne(this.searchFormSelector);
if (this.attachClickListener && this.searchToggle) {
this.searchToggle.addEventListener('click', this.toggleSearch);
}
// Login management
this.loginToggle = queryOne(this.loginToggleSelector);
this.loginBox = queryOne(this.loginBoxSelector);
if (this.attachClickListener && this.loginToggle) {
this.loginToggle.addEventListener('click', this.toggleLogin);
}
// Custom action management
this.customActionToggle = queryOne(this.customActionToggleSelector);
this.customActionOverlay = queryOne(this.customActionOverlaySelector);
this.customActionClose = queryOne(this.customActionCloseSelector);
if (this.customActionOverlay) {
// Create a separate focus trap for the custom action overlay
this.customActionFocusTrap = createFocusTrap(this.customActionOverlay, {
onDeactivate: this.closeCustomAction,
allowOutsideClick: true,
});
}
if (this.attachClickListener && this.customActionToggle) {
this.customActionToggle.addEventListener(
'click',
this.toggleCustomAction,
);
}
if (this.attachClickListener && this.customActionClose) {
this.customActionClose.addEventListener('click', this.toggleCustomAction);
}
if (this.attachKeyListener && this.customActionToggle) {
this.customActionToggle.addEventListener(
'keydown',
this.handleKeyboardCustomAction,
);
}
// Set ecl initialized attribute
this.element.setAttribute('data-ecl-auto-initialized', 'true');
ECL.components.set(this.element, this);
if (this.notification) {
this.notificationContainer = this.notification.closest(
'.ecl-site-header__notification',
);
setTimeout(() => {
const eclNotification = ECL.components.get(this.notification);
if (eclNotification) {
eclNotification.on('onClose', this.handleNotificationClose);
}
}, 0);
}
}
/**
* Destroy component.
*/
destroy() {
if (this.attachClickListener && this.languageLink) {
this.languageLink.removeEventListener('click', this.toggleOverlay);
}
if (this.focusTrap) {
this.focusTrap.deactivate();
}
if (this.attachKeyListener && this.languageLink) {
this.languageLink.removeEventListener(
'keydown',
this.handleKeyboardLanguage,
);
}
if (this.attachClickListener && this.close) {
this.close.removeEventListener('click', this.toggleOverlay);
}
if (this.attachClickListener && this.searchToggle) {
this.searchToggle.removeEventListener('click', this.toggleSearch);
}
if (this.attachClickListener && this.loginToggle) {
this.loginToggle.removeEventListener('click', this.toggleLogin);
}
if (this.attachClickListener && this.customActionToggle) {
this.customActionToggle.removeEventListener(
'click',
this.toggleCustomAction,
);
}
if (this.customActionFocusTrap) {
this.customActionFocusTrap.deactivate();
}
if (this.attachKeyListener && this.customActionToggle) {
this.customActionToggle.removeEventListener(
'keydown',
this.handleKeyboardCustomAction,
);
}
if (this.attachClickListener && this.customActionClose) {
this.customActionClose.removeEventListener(
'click',
this.toggleCustomAction,
);
}
if (this.attachKeyListener) {
document.removeEventListener('keyup', this.handleKeyboardGlobal);
}
if (this.attachClickListener) {
document.removeEventListener('click', this.handleClickGlobal);
}
if (this.attachResizeListener) {
window.removeEventListener('resize', this.handleResize);
}
if (this.element) {
this.element.removeAttribute('data-ecl-auto-initialized');
this.element.classList.remove('ecl-site-header--rtl');
ECL.components.delete(this.element);
}
}
/**
* Method repositions a popover overlay in the viewport.
* It also (optionally) handles language-list–specific logic such as columns.
*
* @param {HTMLElement} overlay The overlay element to be positioned
* @param {HTMLElement} toggle The toggle button/link that opens the overlay
* @param {String} arrowCssVar The CSS variable name for arrow positioning
*/
updateOverlayPosition(
overlay,
toggle,
arrowCssVar = '--ecl-overlay-arrow-position',
) {
if (!overlay || !toggle || !this.container) return;
// If this overlay is the language overlay, handle columns (EU vs Non-EU items)
if (overlay === this.languageListOverlay) {
// Calculate columns for the EU language list
if (this.languageListEu) {
const itemsEu = queryAll(
'.ecl-site-header__language-item',
this.languageListEu,
);
const columnsEu = Math.ceil(
itemsEu.length / this.languageMaxColumnItems,
);
if (columnsEu > 1) {
this.languageListEu.classList.add(
`ecl-site-header__language-category--${columnsEu}-col`,
);
}
}
// Calculate columns for the Non-EU language list
if (this.languageListNonEu) {
const itemsNonEu = queryAll(
'.ecl-site-header__language-item',
this.languageListNonEu,
);
const columnsNonEu = Math.ceil(
itemsNonEu.length / this.languageMaxColumnItems,
);
if (columnsNonEu > 1) {
this.languageListNonEu.classList.add(
`ecl-site-header__language-category--${columnsNonEu}-col`,
);
}
}
// Remove stacked classes first
if (this.languageListEu) {
this.languageListEu.parentNode.classList.remove(
'ecl-site-header__language-content--stack',
);
} else if (this.languageListNonEu) {
this.languageListNonEu.parentNode.classList.remove(
'ecl-site-header__language-content--stack',
);
}
}
// Clear leftover classes/inline styles
overlay.classList.remove(
'ecl-site-header__language-container--push-right',
'ecl-site-header__language-container--push-left',
'ecl-site-header__language-container--full',
);
overlay.style.removeProperty(arrowCssVar);
overlay.style.removeProperty('right');
overlay.style.removeProperty('left');
// Calculate bounding rects
let popoverRect = overlay.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
const screenWidth = window.innerWidth;
const toggleRect = toggle.getBoundingClientRect();
// If this is the language overlay, handle “too wide” scenario
if (overlay === this.languageListOverlay) {
if (popoverRect.width > containerRect.width) {
// Stack elements
if (this.languageListEu) {
this.languageListEu.parentNode.classList.add(
'ecl-site-header__language-content--stack',
);
} else if (this.languageListNonEu) {
this.languageListNonEu.parentNode.classList.add(
'ecl-site-header__language-content--stack',
);
}
}
}
// Mobile: full width if below tablet breakpoint
if (window.innerWidth < this.tabletBreakpoint) {
overlay.classList.add('ecl-site-header__language-container--full');
// Recompute popoverRect after applying the class
popoverRect = overlay.getBoundingClientRect();
// Position arrow
const arrowPosition =
popoverRect.right - toggleRect.right + toggleRect.width / 2;
overlay.style.setProperty(
arrowCssVar,
`calc(${arrowPosition}px - ${this.arrowSize})`,
);
return;
}
// If popover extends beyond right edge in LTR
if (this.direction === 'ltr' && popoverRect.right > screenWidth) {
overlay.classList.add('ecl-site-header__language-container--push-right');
overlay.style.setProperty(
'right',
`calc(-${containerRect.right}px + ${toggleRect.right}px)`,
);
const arrowPos =
containerRect.right - toggleRect.right + toggleRect.width / 2;
overlay.style.setProperty(
arrowCssVar,
`calc(${arrowPos}px - ${this.arrowSize})`,
);
}
// If popover extends beyond left edge in RTL
else if (this.direction === 'rtl' && popoverRect.left < 0) {
overlay.classList.add('ecl-site-header__language-container--push-left');
overlay.style.setProperty(
'left',
`calc(-${toggleRect.left}px + ${containerRect.left}px)`,
);
const arrowPos =
toggleRect.right - containerRect.left - toggleRect.width / 2;
overlay.style.setProperty(arrowCssVar, `${arrowPos}px`);
}
}
/**
* Wrapper: Update display of the modal language list overlay
* (Calls the new `updateOverlayPosition` for the language popover).
*/
updateOverlay() {
if (!this.languageListOverlay || !this.languageLink) return;
this.updateOverlayPosition(this.languageListOverlay, this.languageLink);
}
/**
* Removes the containers of the notification element
*/
handleNotificationClose() {
if (this.notificationContainer) {
this.notificationContainer.remove();
}
}
/**
* Set a max height for the language list content
*/
setLanguageListHeight() {
const viewportHeight = window.innerHeight;
if (this.languageListContent) {
const listTop = this.languageListContent.getBoundingClientRect().top;
const availableSpace = viewportHeight - listTop;
if (availableSpace > 0) {
this.languageListContent.style.maxHeight = `${availableSpace}px`;
}
}
}
/**
* Shows the modal language list overlay.
*/
openOverlay() {
// Display language list
this.languageListOverlay.hidden = false;
this.languageListOverlay.setAttribute('aria-modal', 'true');
this.languageLink.setAttribute('aria-expanded', 'true');
this.setLanguageListHeight();
}
/**
* Hides the modal language list overlay.
*/
closeOverlay() {
this.languageListOverlay.hidden = true;
this.languageListOverlay.removeAttribute('aria-modal');
this.languageLink.setAttribute('aria-expanded', 'false');
}
/**
* Toggles the modal language list overlay.
*
* @param {Event} e
*/
toggleOverlay(e) {
if (!this.languageListOverlay || !this.focusTrap) return;
e.preventDefault();
if (this.languageListOverlay.hasAttribute('hidden')) {
this.openOverlay();
this.updateOverlayPosition(this.languageListOverlay, this.languageLink);
this.focusTrap.activate();
} else {
this.focusTrap.deactivate();
}
}
/**
* Toggles the search form.
*
* @param {Event} e
*/
toggleSearch(e) {
if (!this.searchForm) return;
e.preventDefault();
// Get current status
const isExpanded =
this.searchToggle.getAttribute('aria-expanded') === 'true';
// Close other boxes
if (
this.loginToggle &&
this.loginToggle.getAttribute('aria-expanded') === 'true'
) {
this.toggleLogin(e);
}
// Toggle the search form
this.searchToggle.setAttribute(
'aria-expanded',
isExpanded ? 'false' : 'true',
);
if (!isExpanded) {
this.searchForm.classList.add('ecl-site-header__search--active');
this.setSearchArrow();
} else {
this.searchForm.classList.remove('ecl-site-header__search--active');
}
}
setLoginArrow() {
const loginRect = this.loginBox.getBoundingClientRect();
if (loginRect.x === 0) {
const loginToggleRect = this.loginToggle.getBoundingClientRect();
const arrowPosition =
document.documentElement.clientWidth -
loginToggleRect.right +
loginToggleRect.width / 2;
this.loginBox.style.setProperty(
'--ecl-login-arrow-position',
`calc(${arrowPosition}px - ${this.arrowSize})`,
);
}
}
setSearchArrow() {
const searchRect = this.searchForm.getBoundingClientRect();
if (searchRect.x === 0) {
const searchToggleRect = this.searchToggle.getBoundingClientRect();
const arrowPosition =
document.documentElement.clientWidth -
searchToggleRect.right +
searchToggleRect.width / 2;
this.searchForm.style.setProperty(
'--ecl-search-arrow-position',
`calc(${arrowPosition}px - ${this.arrowSize})`,
);
}
}
/**
* Toggles the login form.
*
* @param {Event} e
*/
toggleLogin(e) {
if (!this.loginBox) return;
e.preventDefault();
// Get current status
const isExpanded =
this.loginToggle.getAttribute('aria-expanded') === 'true';
// Close other boxes
if (
this.searchToggle &&
this.searchToggle.getAttribute('aria-expanded') === 'true'
) {
this.toggleSearch(e);
}
// Toggle the login box
this.loginToggle.setAttribute(
'aria-expanded',
isExpanded ? 'false' : 'true',
);
if (!isExpanded) {
this.loginBox.classList.add('ecl-site-header__login-box--active');
this.setLoginArrow();
} else {
this.loginBox.classList.remove('ecl-site-header__login-box--active');
}
}
/**
* Handles keyboard events specific to the language list.
*
* @param {Event} e
*/
handleKeyboardLanguage(e) {
// Open the menu with space and enter
if (e.keyCode === 32 || e.key === 'Enter') {
this.toggleOverlay(e);
}
}
/**
* Handles global keyboard events, triggered outside of the site header.
*
* @param {Event} e
*/
handleKeyboardGlobal(e) {
if (!this.languageLink) return;
const listExpanded = this.languageLink.getAttribute('aria-expanded');
// Detect press on Escape
if (e.key === 'Escape' || e.key === 'Esc') {
if (listExpanded === 'true') {
this.toggleOverlay(e);
}
if (
this.customActionToggle &&
this.customActionToggle.getAttribute('aria-expanded') === 'true'
) {
this.toggleCustomAction(e);
}
}
}
/**
* Handles global click events, triggered outside of the site header.
*
* @param {Event} e
*/
handleClickGlobal(e) {
if (!this.languageLink && !this.searchToggle && !this.loginToggle) return;
const listExpanded =
this.languageLink && this.languageLink.getAttribute('aria-expanded');
const loginExpanded =
this.loginToggle &&
this.loginToggle.getAttribute('aria-expanded') === 'true';
const searchExpanded =
this.searchToggle &&
this.searchToggle.getAttribute('aria-expanded') === 'true';
// Check if the language list is open
if (listExpanded === 'true') {
// Check if the click occured in the language popover
if (
!this.languageListOverlay.contains(e.target) &&
!this.languageLink.contains(e.target)
) {
this.toggleOverlay(e);
}
}
if (loginExpanded) {
if (
!this.loginBox.contains(e.target) &&
!this.loginToggle.contains(e.target)
) {
this.toggleLogin(e);
}
}
if (searchExpanded) {
if (
!this.searchForm.contains(e.target) &&
!this.searchToggle.contains(e.target)
) {
this.toggleSearch(e);
}
}
// Custom action
const customActionExpanded =
this.customActionToggle &&
this.customActionToggle.getAttribute('aria-expanded') === 'true';
if (customActionExpanded) {
if (
!this.customActionOverlay.contains(e.target) &&
!this.customActionToggle.contains(e.target)
) {
this.toggleCustomAction(e);
}
}
}
/**
* Trigger events on resize
* Uses a debounce, for performance
*/
handleResize() {
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
}
this.resizeTimer = setTimeout(() => {
// If language overlay is open, reposition
if (
this.languageListOverlay &&
!this.languageListOverlay.hasAttribute('hidden')
) {
this.updateOverlayPosition(this.languageListOverlay, this.languageLink);
}
// If custom action overlay is open, reposition
if (
this.customActionOverlay &&
!this.customActionOverlay.hasAttribute('hidden')
) {
this.updateOverlayPosition(
this.customActionOverlay,
this.customActionToggle,
'--ecl-overlay-arrow-position',
);
}
// If the login box is open, re-position arrow
if (
this.loginBox &&
this.loginBox.classList.contains('ecl-site-header__login-box--active')
) {
this.setLoginArrow();
}
// If the search box is open, re-position arrow
if (
this.searchForm &&
this.searchForm.classList.contains('ecl-site-header__search--active')
) {
this.setSearchArrow();
}
}, 200);
}
/**
* Shows the custom action overlay.
*/
openCustomAction() {
if (!this.customActionOverlay) return;
this.customActionOverlay.hidden = false;
this.customActionOverlay.setAttribute('aria-modal', 'true');
this.customActionToggle.setAttribute('aria-expanded', 'true');
}
/**
* Hides the custom action overlay.
*/
closeCustomAction() {
if (!this.customActionOverlay) return;
this.customActionOverlay.hidden = true;
this.customActionOverlay.removeAttribute('aria-modal');
this.customActionToggle.setAttribute('aria-expanded', 'false');
}
/**
* Toggles the custom action overlay.
*
* @param {Event} e
*/
toggleCustomAction(e) {
if (!this.customActionOverlay || !this.customActionFocusTrap) return;
e.preventDefault();
// Check current state
const isHidden = this.customActionOverlay.hasAttribute('hidden');
if (isHidden) {
this.openCustomAction();
// Reuse the same overlay positioning logic,
// but use a different CSS arrow var for custom action if you like.
this.updateOverlayPosition(
this.customActionOverlay,
this.customActionToggle,
'--ecl-overlay-arrow-position',
);
this.customActionFocusTrap.activate();
} else {
this.customActionFocusTrap.deactivate();
}
}
/**
* Handles keyboard events specific to the custom action toggle.
*
* @param {Event} e
*/
handleKeyboardCustomAction(e) {
// Open the custom action with space and enter
if (e.keyCode === 32 || e.key === 'Enter') {
this.toggleCustomAction(e);
}
}
}
export default SiteHeader;