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