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