site-header.js

  1. import { queryOne, queryAll } from '@ecl/dom-utils';
  2. import { createFocusTrap } from 'focus-trap';
  3. /**
  4. * @param {HTMLElement} element DOM element for component instantiation and scope
  5. * @param {Object} options
  6. * @param {String} options.languageLinkSelector
  7. * @param {String} options.languageListOverlaySelector
  8. * @param {String} options.languageListEuSelector
  9. * @param {String} options.languageListNonEuSelector
  10. * @param {String} options.closeOverlaySelector
  11. * @param {String} options.searchToggleSelector
  12. * @param {String} options.searchFormSelector
  13. * @param {String} options.loginToggleSelector
  14. * @param {String} options.loginBoxSelector
  15. * @param {Boolean} options.attachClickListener Whether or not to bind click events
  16. * @param {Boolean} options.attachKeyListener Whether or not to bind keyboard events
  17. * @param {Boolean} options.attachResizeListener Whether or not to bind resize events
  18. */
  19. export class SiteHeader {
  20. /**
  21. * @static
  22. * Shorthand for instance creation and initialisation.
  23. *
  24. * @param {HTMLElement} root DOM element for component instantiation and scope
  25. *
  26. * @return {SiteHeader} An instance of SiteHeader.
  27. */
  28. static autoInit(root, { SITE_HEADER_CORE: defaultOptions = {} } = {}) {
  29. const siteHeader = new SiteHeader(root, defaultOptions);
  30. siteHeader.init();
  31. root.ECLSiteHeader = siteHeader;
  32. return siteHeader;
  33. }
  34. constructor(
  35. element,
  36. {
  37. containerSelector = '[data-ecl-site-header-top]',
  38. languageLinkSelector = '[data-ecl-language-selector]',
  39. languageListOverlaySelector = '[data-ecl-language-list-overlay]',
  40. languageListEuSelector = '[data-ecl-language-list-eu]',
  41. languageListNonEuSelector = '[data-ecl-language-list-non-eu]',
  42. closeOverlaySelector = '[data-ecl-language-list-close]',
  43. searchToggleSelector = '[data-ecl-search-toggle]',
  44. searchFormSelector = '[data-ecl-search-form]',
  45. loginToggleSelector = '[data-ecl-login-toggle]',
  46. loginBoxSelector = '[data-ecl-login-box]',
  47. attachClickListener = true,
  48. attachKeyListener = true,
  49. attachResizeListener = true,
  50. } = {},
  51. ) {
  52. // Check element
  53. if (!element || element.nodeType !== Node.ELEMENT_NODE) {
  54. throw new TypeError(
  55. 'DOM element should be given to initialize this widget.',
  56. );
  57. }
  58. this.element = element;
  59. // Options
  60. this.containerSelector = containerSelector;
  61. this.languageLinkSelector = languageLinkSelector;
  62. this.languageListOverlaySelector = languageListOverlaySelector;
  63. this.languageListEuSelector = languageListEuSelector;
  64. this.languageListNonEuSelector = languageListNonEuSelector;
  65. this.closeOverlaySelector = closeOverlaySelector;
  66. this.searchToggleSelector = searchToggleSelector;
  67. this.searchFormSelector = searchFormSelector;
  68. this.loginToggleSelector = loginToggleSelector;
  69. this.loginBoxSelector = loginBoxSelector;
  70. this.attachClickListener = attachClickListener;
  71. this.attachKeyListener = attachKeyListener;
  72. this.attachResizeListener = attachResizeListener;
  73. // Private variables
  74. this.languageMaxColumnItems = 8;
  75. this.languageLink = null;
  76. this.languageListOverlay = null;
  77. this.languageListEu = null;
  78. this.languageListNonEu = null;
  79. this.close = null;
  80. this.focusTrap = null;
  81. this.searchToggle = null;
  82. this.searchForm = null;
  83. this.loginToggle = null;
  84. this.loginBox = null;
  85. this.resizeTimer = null;
  86. // Bind `this` for use in callbacks
  87. this.openOverlay = this.openOverlay.bind(this);
  88. this.closeOverlay = this.closeOverlay.bind(this);
  89. this.toggleOverlay = this.toggleOverlay.bind(this);
  90. this.toggleSearch = this.toggleSearch.bind(this);
  91. this.toggleLogin = this.toggleLogin.bind(this);
  92. this.setLoginArrow = this.setLoginArrow.bind(this);
  93. this.setSearchArrow = this.setSearchArrow.bind(this);
  94. this.handleKeyboardLanguage = this.handleKeyboardLanguage.bind(this);
  95. this.handleKeyboardGlobal = this.handleKeyboardGlobal.bind(this);
  96. this.handleClickGlobal = this.handleClickGlobal.bind(this);
  97. this.handleResize = this.handleResize.bind(this);
  98. }
  99. /**
  100. * Initialise component.
  101. */
  102. init() {
  103. if (!ECL) {
  104. throw new TypeError('Called init but ECL is not present');
  105. }
  106. ECL.components = ECL.components || new Map();
  107. this.arrowSize = '0.5rem';
  108. // Bind global events
  109. if (this.attachKeyListener) {
  110. document.addEventListener('keyup', this.handleKeyboardGlobal);
  111. }
  112. if (this.attachClickListener) {
  113. document.addEventListener('click', this.handleClickGlobal);
  114. }
  115. if (this.attachResizeListener) {
  116. window.addEventListener('resize', this.handleResize);
  117. }
  118. // Site header elements
  119. this.container = queryOne(this.containerSelector);
  120. // Language list management
  121. this.languageLink = queryOne(this.languageLinkSelector);
  122. this.languageListOverlay = queryOne(this.languageListOverlaySelector);
  123. this.languageListEu = queryOne(this.languageListEuSelector);
  124. this.languageListNonEu = queryOne(this.languageListNonEuSelector);
  125. this.close = queryOne(this.closeOverlaySelector);
  126. // Create focus trap
  127. this.focusTrap = createFocusTrap(this.languageListOverlay, {
  128. onDeactivate: this.closeOverlay,
  129. allowOutsideClick: true,
  130. });
  131. if (this.attachClickListener && this.languageLink) {
  132. this.languageLink.addEventListener('click', this.toggleOverlay);
  133. }
  134. if (this.attachClickListener && this.close) {
  135. this.close.addEventListener('click', this.toggleOverlay);
  136. }
  137. if (this.attachKeyListener && this.languageLink) {
  138. this.languageLink.addEventListener(
  139. 'keydown',
  140. this.handleKeyboardLanguage,
  141. );
  142. }
  143. // Search form management
  144. this.searchToggle = queryOne(this.searchToggleSelector);
  145. this.searchForm = queryOne(this.searchFormSelector);
  146. if (this.attachClickListener && this.searchToggle) {
  147. this.searchToggle.addEventListener('click', this.toggleSearch);
  148. }
  149. // Login management
  150. this.loginToggle = queryOne(this.loginToggleSelector);
  151. this.loginBox = queryOne(this.loginBoxSelector);
  152. if (this.attachClickListener && this.loginToggle) {
  153. this.loginToggle.addEventListener('click', this.toggleLogin);
  154. }
  155. // Set ecl initialized attribute
  156. this.element.setAttribute('data-ecl-auto-initialized', 'true');
  157. ECL.components.set(this.element, this);
  158. }
  159. /**
  160. * Destroy component.
  161. */
  162. destroy() {
  163. if (this.attachClickListener && this.languageLink) {
  164. this.languageLink.removeEventListener('click', this.toggleOverlay);
  165. }
  166. if (this.focusTrap) {
  167. this.focusTrap.deactivate();
  168. }
  169. if (this.attachKeyListener && this.languageLink) {
  170. this.languageLink.removeEventListener(
  171. 'keydown',
  172. this.handleKeyboardLanguage,
  173. );
  174. }
  175. if (this.attachClickListener && this.close) {
  176. this.close.removeEventListener('click', this.toggleOverlay);
  177. }
  178. if (this.attachClickListener && this.searchToggle) {
  179. this.searchToggle.removeEventListener('click', this.toggleSearch);
  180. }
  181. if (this.attachClickListener && this.loginToggle) {
  182. this.loginToggle.removeEventListener('click', this.toggleLogin);
  183. }
  184. if (this.attachKeyListener) {
  185. document.removeEventListener('keyup', this.handleKeyboardGlobal);
  186. }
  187. if (this.attachClickListener) {
  188. document.removeEventListener('click', this.handleClickGlobal);
  189. }
  190. if (this.attachResizeListener) {
  191. window.removeEventListener('resize', this.handleResize);
  192. }
  193. if (this.element) {
  194. this.element.removeAttribute('data-ecl-auto-initialized');
  195. ECL.components.delete(this.element);
  196. }
  197. }
  198. /**
  199. * Update display of the modal language list overlay.
  200. */
  201. updateOverlay() {
  202. // Check number of items and adapt display
  203. let columnsEu = 1;
  204. let columnsNonEu = 1;
  205. if (this.languageListEu) {
  206. // Get all Eu languages
  207. const itemsEu = queryAll(
  208. '.ecl-site-header__language-item',
  209. this.languageListEu,
  210. );
  211. // Calculate number of columns
  212. columnsEu = Math.ceil(itemsEu.length / this.languageMaxColumnItems);
  213. // Apply column display
  214. if (columnsEu > 1) {
  215. this.languageListEu.classList.add(
  216. `ecl-site-header__language-category--${columnsEu}-col`,
  217. );
  218. }
  219. }
  220. if (this.languageListNonEu) {
  221. // Get all non-Eu languages
  222. const itemsNonEu = queryAll(
  223. '.ecl-site-header__language-item',
  224. this.languageListNonEu,
  225. );
  226. // Calculate number of columns
  227. columnsNonEu = Math.ceil(itemsNonEu.length / this.languageMaxColumnItems);
  228. // Apply column display
  229. if (columnsNonEu > 1) {
  230. this.languageListNonEu.classList.add(
  231. `ecl-site-header__language-category--${columnsNonEu}-col`,
  232. );
  233. }
  234. }
  235. // Check total width, and change display if needed
  236. if (this.languageListEu) {
  237. this.languageListEu.parentNode.classList.remove(
  238. 'ecl-site-header__language-content--stack',
  239. );
  240. } else if (this.languageListNonEu) {
  241. this.languageListNonEu.parentNode.classList.remove(
  242. 'ecl-site-header__language-content--stack',
  243. );
  244. }
  245. let popoverRect = this.languageListOverlay.getBoundingClientRect();
  246. const containerRect = this.container.getBoundingClientRect();
  247. if (popoverRect.width > containerRect.width) {
  248. // Stack elements
  249. if (this.languageListEu) {
  250. this.languageListEu.parentNode.classList.add(
  251. 'ecl-site-header__language-content--stack',
  252. );
  253. } else if (this.languageListNonEu) {
  254. this.languageListNonEu.parentNode.classList.add(
  255. 'ecl-site-header__language-content--stack',
  256. );
  257. }
  258. // Adapt column display
  259. if (this.languageListNonEu) {
  260. this.languageListNonEu.classList.remove(
  261. `ecl-site-header__language-category--${columnsNonEu}-col`,
  262. );
  263. this.languageListNonEu.classList.add(
  264. `ecl-site-header__language-category--${Math.max(
  265. columnsEu,
  266. columnsNonEu,
  267. )}-col`,
  268. );
  269. }
  270. }
  271. // Check available space
  272. this.languageListOverlay.classList.remove(
  273. 'ecl-site-header__language-container--push-right',
  274. );
  275. this.languageListOverlay.classList.remove(
  276. 'ecl-site-header__language-container--full',
  277. );
  278. this.languageListOverlay.style.removeProperty(
  279. '--ecl-language-arrow-position',
  280. );
  281. this.languageListOverlay.style.removeProperty('right');
  282. popoverRect = this.languageListOverlay.getBoundingClientRect();
  283. const screenWidth = window.innerWidth;
  284. // Popover too large
  285. if (popoverRect.right > screenWidth) {
  286. const linkRect = this.languageLink.getBoundingClientRect();
  287. // Push the popover to the right
  288. this.languageListOverlay.classList.add(
  289. 'ecl-site-header__language-container--push-right',
  290. );
  291. this.languageListOverlay.style.setProperty(
  292. 'right',
  293. `-${containerRect.right - linkRect.right}px`,
  294. );
  295. // Adapt arrow position
  296. const arrowPosition =
  297. containerRect.right - linkRect.right + linkRect.width / 2;
  298. this.languageListOverlay.style.setProperty(
  299. '--ecl-language-arrow-position',
  300. `calc(${arrowPosition}px - ${this.arrowSize})`,
  301. );
  302. }
  303. // Mobile popover (full width)
  304. if (popoverRect.left === 0) {
  305. const linkRect = this.languageLink.getBoundingClientRect();
  306. // Push the popover to the right
  307. this.languageListOverlay.classList.add(
  308. 'ecl-site-header__language-container--full',
  309. );
  310. this.languageListOverlay.style.removeProperty('right');
  311. // Adapt arrow position
  312. const arrowPosition =
  313. popoverRect.right - linkRect.right + linkRect.width / 2;
  314. this.languageListOverlay.style.setProperty(
  315. '--ecl-language-arrow-position',
  316. `calc(${arrowPosition}px - ${this.arrowSize})`,
  317. );
  318. }
  319. if (
  320. this.loginBox &&
  321. this.loginBox.classList.contains('ecl-site-header__login-box--active')
  322. ) {
  323. this.setLoginArrow();
  324. }
  325. if (
  326. this.searchForm &&
  327. this.searchForm.classList.contains('ecl-site-header__search--active')
  328. ) {
  329. this.setSearchArrow();
  330. }
  331. }
  332. /**
  333. * Shows the modal language list overlay.
  334. */
  335. openOverlay() {
  336. // Display language list
  337. this.languageListOverlay.hidden = false;
  338. this.languageListOverlay.setAttribute('aria-modal', 'true');
  339. this.languageLink.setAttribute('aria-expanded', 'true');
  340. }
  341. /**
  342. * Hides the modal language list overlay.
  343. */
  344. closeOverlay() {
  345. this.languageListOverlay.hidden = true;
  346. this.languageListOverlay.removeAttribute('aria-modal');
  347. this.languageLink.setAttribute('aria-expanded', 'false');
  348. }
  349. /**
  350. * Toggles the modal language list overlay.
  351. *
  352. * @param {Event} e
  353. */
  354. toggleOverlay(e) {
  355. if (!this.languageListOverlay || !this.focusTrap) return;
  356. e.preventDefault();
  357. if (this.languageListOverlay.hasAttribute('hidden')) {
  358. this.openOverlay();
  359. this.updateOverlay();
  360. this.focusTrap.activate();
  361. } else {
  362. this.focusTrap.deactivate();
  363. }
  364. }
  365. /**
  366. * Trigger events on resize
  367. * Uses a debounce, for performance
  368. */
  369. handleResize() {
  370. if (
  371. !this.languageListOverlay ||
  372. this.languageListOverlay.hasAttribute('hidden')
  373. )
  374. return;
  375. if (
  376. (this.loginBox &&
  377. this.loginBox.classList.contains(
  378. 'ecl-site-header__login-box--active',
  379. )) ||
  380. (this.searchForm &&
  381. this.searchForm.classList.contains('ecl-site-header__search--active'))
  382. ) {
  383. clearTimeout(this.resizeTimer);
  384. this.resizeTimer = setTimeout(() => {
  385. this.updateOverlay();
  386. }, 200);
  387. }
  388. }
  389. /**
  390. * Handles keyboard events specific to the language list.
  391. *
  392. * @param {Event} e
  393. */
  394. handleKeyboardLanguage(e) {
  395. // Open the menu with space and enter
  396. if (e.keyCode === 32 || e.key === 'Enter') {
  397. this.toggleOverlay(e);
  398. }
  399. }
  400. /**
  401. * Toggles the search form.
  402. *
  403. * @param {Event} e
  404. */
  405. toggleSearch(e) {
  406. if (!this.searchForm) return;
  407. e.preventDefault();
  408. // Get current status
  409. const isExpanded =
  410. this.searchToggle.getAttribute('aria-expanded') === 'true';
  411. // Close other boxes
  412. if (
  413. this.loginToggle &&
  414. this.loginToggle.getAttribute('aria-expanded') === 'true'
  415. ) {
  416. this.toggleLogin(e);
  417. }
  418. // Toggle the search form
  419. this.searchToggle.setAttribute(
  420. 'aria-expanded',
  421. isExpanded ? 'false' : 'true',
  422. );
  423. if (!isExpanded) {
  424. this.searchForm.classList.add('ecl-site-header__search--active');
  425. this.setSearchArrow();
  426. } else {
  427. this.searchForm.classList.remove('ecl-site-header__search--active');
  428. }
  429. }
  430. setLoginArrow() {
  431. const loginRect = this.loginBox.getBoundingClientRect();
  432. if (loginRect.x === 0) {
  433. const loginToggleRect = this.loginToggle.getBoundingClientRect();
  434. const arrowPosition =
  435. window.innerWidth - loginToggleRect.right + loginToggleRect.width / 2;
  436. this.loginBox.style.setProperty(
  437. '--ecl-login-arrow-position',
  438. `calc(${arrowPosition}px - ${this.arrowSize})`,
  439. );
  440. }
  441. }
  442. setSearchArrow() {
  443. const searchRect = this.searchForm.getBoundingClientRect();
  444. if (searchRect.x === 0) {
  445. const searchToggleRect = this.searchToggle.getBoundingClientRect();
  446. const arrowPosition =
  447. window.innerWidth - searchToggleRect.right + searchToggleRect.width / 2;
  448. this.searchForm.style.setProperty(
  449. '--ecl-search-arrow-position',
  450. `calc(${arrowPosition}px - ${this.arrowSize})`,
  451. );
  452. }
  453. }
  454. /**
  455. * Toggles the login form.
  456. *
  457. * @param {Event} e
  458. */
  459. toggleLogin(e) {
  460. if (!this.loginBox) return;
  461. e.preventDefault();
  462. // Get current status
  463. const isExpanded =
  464. this.loginToggle.getAttribute('aria-expanded') === 'true';
  465. // Close other boxes
  466. if (
  467. this.searchToggle &&
  468. this.searchToggle.getAttribute('aria-expanded') === 'true'
  469. ) {
  470. this.toggleSearch(e);
  471. }
  472. // Toggle the login box
  473. this.loginToggle.setAttribute(
  474. 'aria-expanded',
  475. isExpanded ? 'false' : 'true',
  476. );
  477. if (!isExpanded) {
  478. this.loginBox.classList.add('ecl-site-header__login-box--active');
  479. this.setLoginArrow();
  480. } else {
  481. this.loginBox.classList.remove('ecl-site-header__login-box--active');
  482. }
  483. }
  484. /**
  485. * Handles global keyboard events, triggered outside of the site header.
  486. *
  487. * @param {Event} e
  488. */
  489. handleKeyboardGlobal(e) {
  490. if (!this.languageLink) return;
  491. const listExpanded = this.languageLink.getAttribute('aria-expanded');
  492. // Detect press on Escape
  493. if (e.key === 'Escape' || e.key === 'Esc') {
  494. if (listExpanded === 'true') {
  495. this.toggleOverlay(e);
  496. }
  497. }
  498. }
  499. /**
  500. * Handles global click events, triggered outside of the site header.
  501. *
  502. * @param {Event} e
  503. */
  504. handleClickGlobal(e) {
  505. if (!this.languageLink && !this.searchToggle && !this.loginToggle) return;
  506. const listExpanded =
  507. this.languageLink && this.languageLink.getAttribute('aria-expanded');
  508. const loginExpanded =
  509. this.loginToggle &&
  510. this.loginToggle.getAttribute('aria-expanded') === 'true';
  511. const searchExpanded =
  512. this.searchToggle &&
  513. this.searchToggle.getAttribute('aria-expanded') === 'true';
  514. // Check if the language list is open
  515. if (listExpanded === 'true') {
  516. // Check if the click occured in the language popover
  517. if (
  518. !this.languageListOverlay.contains(e.target) &&
  519. !this.languageLink.contains(e.target)
  520. ) {
  521. this.toggleOverlay(e);
  522. }
  523. }
  524. if (loginExpanded) {
  525. if (
  526. !this.loginBox.contains(e.target) &&
  527. !this.loginToggle.contains(e.target)
  528. ) {
  529. this.toggleLogin(e);
  530. }
  531. }
  532. if (searchExpanded) {
  533. if (
  534. !this.searchForm.contains(e.target) &&
  535. !this.searchToggle.contains(e.target)
  536. ) {
  537. this.toggleSearch(e);
  538. }
  539. }
  540. }
  541. }
  542. export default SiteHeader;