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]',
attachClickListener = true,
attachKeyListener = true,
attachResizeListener = true,
tabletBreakpoint = 768,
} = {},
) {
// 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.loginBoxSelector = loginBoxSelector;
this.attachClickListener = attachClickListener;
this.attachKeyListener = attachKeyListener;
this.attachResizeListener = attachResizeListener;
this.tabletBreakpoint = tabletBreakpoint;
// 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;
// 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);
}
/**
* 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);
// direction
this.direction = getComputedStyle(this.element).direction;
if (this.direction === 'rtl') {
this.element.classList.add('ecl-site-header--rtl');
}
// Create focus trap
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);
}
// Set ecl initialized attribute
this.element.setAttribute('data-ecl-auto-initialized', 'true');
ECL.components.set(this.element, this);
}
/**
* 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.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);
}
}
/**
* Update display of the modal language list overlay.
*/
updateOverlay() {
// Check number of items and adapt display
let columnsEu = 1;
let columnsNonEu = 1;
if (this.languageListEu) {
// Get all Eu languages
const itemsEu = queryAll(
'.ecl-site-header__language-item',
this.languageListEu,
);
// Calculate number of columns
columnsEu = Math.ceil(itemsEu.length / this.languageMaxColumnItems);
// Apply column display
if (columnsEu > 1) {
this.languageListEu.classList.add(
`ecl-site-header__language-category--${columnsEu}-col`,
);
}
}
if (this.languageListNonEu) {
// Get all non-Eu languages
const itemsNonEu = queryAll(
'.ecl-site-header__language-item',
this.languageListNonEu,
);
// Calculate number of columns
columnsNonEu = Math.ceil(itemsNonEu.length / this.languageMaxColumnItems);
// Apply column display
if (columnsNonEu > 1) {
this.languageListNonEu.classList.add(
`ecl-site-header__language-category--${columnsNonEu}-col`,
);
}
}
// Check total width, and change display if needed
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',
);
}
let popoverRect = this.languageListOverlay.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect();
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',
);
}
// Adapt column display
if (this.languageListNonEu) {
this.languageListNonEu.classList.remove(
`ecl-site-header__language-category--${columnsNonEu}-col`,
);
this.languageListNonEu.classList.add(
`ecl-site-header__language-category--${Math.max(
columnsEu,
columnsNonEu,
)}-col`,
);
}
}
// Check available space
this.languageListOverlay.classList.remove(
'ecl-site-header__language-container--push-right',
'ecl-site-header__language-container--push-left',
);
this.languageListOverlay.classList.remove(
'ecl-site-header__language-container--full',
);
this.languageListOverlay.style.removeProperty(
'--ecl-language-arrow-position',
);
this.languageListOverlay.style.removeProperty('right');
this.languageListOverlay.style.removeProperty('left');
popoverRect = this.languageListOverlay.getBoundingClientRect();
const screenWidth = window.innerWidth;
const linkRect = this.languageLink.getBoundingClientRect();
// Popover too large
if (this.direction === 'ltr' && popoverRect.right > screenWidth) {
// Push the popover to the right
this.languageListOverlay.classList.add(
'ecl-site-header__language-container--push-right',
);
this.languageListOverlay.style.setProperty(
'right',
`calc(-${containerRect.right}px + ${linkRect.right}px)`,
);
// Adapt arrow position
const arrowPosition =
containerRect.right - linkRect.right + linkRect.width / 2;
this.languageListOverlay.style.setProperty(
'--ecl-language-arrow-position',
`calc(${arrowPosition}px - ${this.arrowSize})`,
);
} else if (this.direction === 'rtl' && popoverRect.left < 0) {
this.languageListOverlay.classList.add(
'ecl-site-header__language-container--push-left',
);
this.languageListOverlay.style.setProperty(
'left',
`calc(-${linkRect.left}px + ${containerRect.left}px)`,
);
// Adapt arrow position
const arrowPosition =
linkRect.right - containerRect.left - linkRect.width / 2;
this.languageListOverlay.style.setProperty(
'--ecl-language-arrow-position',
`${arrowPosition}px`,
);
}
// Mobile popover (full width)
if (window.innerWidth < this.tabletBreakpoint) {
// Push the popover to the right
this.languageListOverlay.classList.add(
'ecl-site-header__language-container--full',
);
this.languageListOverlay.style.removeProperty('right');
// Adapt arrow position
const arrowPosition =
popoverRect.right - linkRect.right + linkRect.width / 2;
this.languageListOverlay.style.setProperty(
'--ecl-language-arrow-position',
`calc(${arrowPosition}px - ${this.arrowSize})`,
);
}
if (
this.loginBox &&
this.loginBox.classList.contains('ecl-site-header__login-box--active')
) {
this.setLoginArrow();
}
if (
this.searchForm &&
this.searchForm.classList.contains('ecl-site-header__search--active')
) {
this.setSearchArrow();
}
}
/**
* 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.updateOverlay();
this.focusTrap.activate();
} else {
this.focusTrap.deactivate();
}
}
/**
* Trigger events on resize
* Uses a debounce, for performance
*/
handleResize() {
if (
!this.languageListOverlay ||
this.languageListOverlay.hasAttribute('hidden')
)
return;
if (
(this.loginBox &&
this.loginBox.classList.contains(
'ecl-site-header__login-box--active',
)) ||
(this.searchForm &&
this.searchForm.classList.contains('ecl-site-header__search--active'))
) {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.updateOverlay();
}, 200);
}
}
/**
* 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);
}
}
/**
* 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 =
window.innerWidth - 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 =
window.innerWidth - 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 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);
}
}
}
/**
* 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);
}
}
}
}
export default SiteHeader;