From 608e29cbe454db3c3223243fb494fbf36eb2b7f5 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 20 Jul 2023 07:54:46 +0100 Subject: [PATCH 1/4] Convert all components to ES2015 classes --- src/javascripts/application.mjs | 2 +- src/javascripts/components/back-to-top.mjs | 102 +++---- src/javascripts/components/cookie-banner.mjs | 130 ++++----- src/javascripts/components/cookies-page.mjs | 112 ++++---- src/javascripts/components/copy.mjs | 65 ++--- src/javascripts/components/example-page.mjs | 39 +-- src/javascripts/components/example.mjs | 48 ++-- src/javascripts/components/navigation.mjs | 205 +++++++------- src/javascripts/components/options-table.mjs | 11 +- src/javascripts/components/search.mjs | 250 ++++++++--------- src/javascripts/components/tabs.mjs | 268 ++++++++++--------- 11 files changed, 632 insertions(+), 600 deletions(-) diff --git a/src/javascripts/application.mjs b/src/javascripts/application.mjs index 57d063be6c..30123f33c3 100644 --- a/src/javascripts/application.mjs +++ b/src/javascripts/application.mjs @@ -38,7 +38,7 @@ $tabs.forEach(($tabs) => { }) // Do this after initialising tabs -OptionsTable.init() +new OptionsTable().init() // Add copy to clipboard to code blocks inside tab containers const $codeBlocks = document.querySelectorAll('[data-module="app-copy"] pre') diff --git a/src/javascripts/components/back-to-top.mjs b/src/javascripts/components/back-to-top.mjs index f91ae6e1a8..9937019de1 100644 --- a/src/javascripts/components/back-to-top.mjs +++ b/src/javascripts/components/back-to-top.mjs @@ -1,63 +1,65 @@ -function BackToTop ($module) { - this.$module = $module -} - -BackToTop.prototype.init = function () { - if (!this.$module) { - return +class BackToTop { + constructor ($module) { + this.$module = $module } - // Check if we can use Intersection Observers - if (!('IntersectionObserver' in window)) { - // If there's no support fallback to regular behaviour - // Since JavaScript is enabled we can remove the default hidden state - return this.$module.classList.remove('app-back-to-top--hidden') - } + init () { + if (!this.$module) { + return + } - const $footer = document.querySelector('.app-footer') - const $subNav = document.querySelector('.app-subnav') + // Check if we can use Intersection Observers + if (!('IntersectionObserver' in window)) { + // If there's no support fallback to regular behaviour + // Since JavaScript is enabled we can remove the default hidden state + return this.$module.classList.remove('app-back-to-top--hidden') + } - // Check if there is anything to observe - if (!$footer || !$subNav) { - return - } + const $footer = document.querySelector('.app-footer') + const $subNav = document.querySelector('.app-subnav') - let footerIsIntersecting = false - let subNavIsIntersecting = false - let subNavIntersectionRatio = 0 + // Check if there is anything to observe + if (!$footer || !$subNav) { + return + } - const observer = new window.IntersectionObserver((entries) => { - // Find the elements we care about from the entries - const footerEntry = entries.find((entry) => entry.target === $footer) - const subNavEntry = entries.find((entry) => entry.target === $subNav) + let footerIsIntersecting = false + let subNavIsIntersecting = false + let subNavIntersectionRatio = 0 - // If there is an entry this means the element has changed so lets check if it's intersecting. - if (footerEntry) { - footerIsIntersecting = footerEntry.isIntersecting - } - if (subNavEntry) { - subNavIsIntersecting = subNavEntry.isIntersecting - subNavIntersectionRatio = subNavEntry.intersectionRatio - } + const observer = new window.IntersectionObserver((entries) => { + // Find the elements we care about from the entries + const footerEntry = entries.find((entry) => entry.target === $footer) + const subNavEntry = entries.find((entry) => entry.target === $subNav) - // If the subnav or the footer not visible then fix the back to top link to follow the user - if (subNavIsIntersecting || footerIsIntersecting) { - this.$module.classList.remove('app-back-to-top--fixed') - } else { - this.$module.classList.add('app-back-to-top--fixed') - } + // If there is an entry this means the element has changed so lets check if it's intersecting. + if (footerEntry) { + footerIsIntersecting = footerEntry.isIntersecting + } + if (subNavEntry) { + subNavIsIntersecting = subNavEntry.isIntersecting + subNavIntersectionRatio = subNavEntry.intersectionRatio + } - // If the subnav is visible but you can see it all at once, then a back to top link is likely not as useful. - // We hide the link but make it focusable for screen readers users who might still find it useful. - if (subNavIsIntersecting && subNavIntersectionRatio === 1) { - this.$module.classList.add('app-back-to-top--hidden') - } else { - this.$module.classList.remove('app-back-to-top--hidden') - } - }) + // If the subnav or the footer not visible then fix the back to top link to follow the user + if (subNavIsIntersecting || footerIsIntersecting) { + this.$module.classList.remove('app-back-to-top--fixed') + } else { + this.$module.classList.add('app-back-to-top--fixed') + } + + // If the subnav is visible but you can see it all at once, then a back to top link is likely not as useful. + // We hide the link but make it focusable for screen readers users who might still find it useful. + if (subNavIsIntersecting && subNavIntersectionRatio === 1) { + this.$module.classList.add('app-back-to-top--hidden') + } else { + this.$module.classList.remove('app-back-to-top--hidden') + } + }) - observer.observe($footer) - observer.observe($subNav) + observer.observe($footer) + observer.observe($subNav) + } } export default BackToTop diff --git a/src/javascripts/components/cookie-banner.mjs b/src/javascripts/components/cookie-banner.mjs index 1421f50a0a..36d0e71eeb 100644 --- a/src/javascripts/components/cookie-banner.mjs +++ b/src/javascripts/components/cookie-banner.mjs @@ -7,84 +7,86 @@ const cookieMessageSelector = '.js-cookie-banner-message' const cookieConfirmationAcceptSelector = '.js-cookie-banner-confirmation-accept' const cookieConfirmationRejectSelector = '.js-cookie-banner-confirmation-reject' -function CookieBanner ($module) { - this.$module = $module -} - -CookieBanner.prototype.init = function () { - this.$cookieBanner = this.$module - this.$acceptButton = this.$module.querySelector(cookieBannerAcceptSelector) - this.$rejectButton = this.$module.querySelector(cookieBannerRejectSelector) - this.$cookieMessage = this.$module.querySelector(cookieMessageSelector) - this.$cookieConfirmationAccept = this.$module.querySelector(cookieConfirmationAcceptSelector) - this.$cookieConfirmationReject = this.$module.querySelector(cookieConfirmationRejectSelector) - this.$cookieBannerHideButtons = this.$module.querySelectorAll(cookieBannerHideButtonSelector) - - // Exit if no cookie banner module - // or if we're on the cookies page to avoid circular journeys - if (!this.$cookieBanner || this.onCookiesPage()) { - return +class CookieBanner { + constructor ($module) { + this.$module = $module } - // Show the cookie banner to users who have not consented or have an - // outdated consent cookie - const currentConsentCookie = CookieFunctions.getConsentCookie() - - if (!currentConsentCookie || !CookieFunctions.isValidConsentCookie(currentConsentCookie)) { - // If the consent cookie version is not valid, we need to remove any cookies which have been - // set previously - CookieFunctions.resetCookies() - - this.$cookieBanner.removeAttribute('hidden') + init () { + this.$cookieBanner = this.$module + this.$acceptButton = this.$module.querySelector(cookieBannerAcceptSelector) + this.$rejectButton = this.$module.querySelector(cookieBannerRejectSelector) + this.$cookieMessage = this.$module.querySelector(cookieMessageSelector) + this.$cookieConfirmationAccept = this.$module.querySelector(cookieConfirmationAcceptSelector) + this.$cookieConfirmationReject = this.$module.querySelector(cookieConfirmationRejectSelector) + this.$cookieBannerHideButtons = this.$module.querySelectorAll(cookieBannerHideButtonSelector) + + // Exit if no cookie banner module + // or if we're on the cookies page to avoid circular journeys + if (!this.$cookieBanner || this.onCookiesPage()) { + return + } + + // Show the cookie banner to users who have not consented or have an + // outdated consent cookie + const currentConsentCookie = CookieFunctions.getConsentCookie() + + if (!currentConsentCookie || !CookieFunctions.isValidConsentCookie(currentConsentCookie)) { + // If the consent cookie version is not valid, we need to remove any cookies which have been + // set previously + CookieFunctions.resetCookies() + + this.$cookieBanner.removeAttribute('hidden') + } + + this.$acceptButton.addEventListener('click', () => this.acceptCookies()) + this.$rejectButton.addEventListener('click', () => this.rejectCookies()) + + this.$cookieBannerHideButtons.forEach(($cookieBannerHideButton) => { + $cookieBannerHideButton.addEventListener('click', () => this.hideBanner()) + }) } - this.$acceptButton.addEventListener('click', () => this.acceptCookies()) - this.$rejectButton.addEventListener('click', () => this.rejectCookies()) - - this.$cookieBannerHideButtons.forEach(($cookieBannerHideButton) => { - $cookieBannerHideButton.addEventListener('click', () => this.hideBanner()) - }) -} + hideBanner () { + this.$cookieBanner.setAttribute('hidden', true) + } -CookieBanner.prototype.hideBanner = function () { - this.$cookieBanner.setAttribute('hidden', true) -} + acceptCookies () { + // Do actual cookie consent bit + CookieFunctions.setConsentCookie({ analytics: true }) -CookieBanner.prototype.acceptCookies = function () { - // Do actual cookie consent bit - CookieFunctions.setConsentCookie({ analytics: true }) + // Hide banner and show confirmation message + this.$cookieMessage.setAttribute('hidden', true) + this.revealConfirmationMessage(this.$cookieConfirmationAccept) + } - // Hide banner and show confirmation message - this.$cookieMessage.setAttribute('hidden', true) - this.revealConfirmationMessage(this.$cookieConfirmationAccept) -} + rejectCookies () { + // Do actual cookie consent bit + CookieFunctions.setConsentCookie({ analytics: false }) -CookieBanner.prototype.rejectCookies = function () { - // Do actual cookie consent bit - CookieFunctions.setConsentCookie({ analytics: false }) + // Hide banner and show confirmation message + this.$cookieMessage.setAttribute('hidden', true) + this.revealConfirmationMessage(this.$cookieConfirmationReject) + } - // Hide banner and show confirmation message - this.$cookieMessage.setAttribute('hidden', true) - this.revealConfirmationMessage(this.$cookieConfirmationReject) -} + revealConfirmationMessage (confirmationMessage) { + confirmationMessage.removeAttribute('hidden') -CookieBanner.prototype.revealConfirmationMessage = function (confirmationMessage) { - confirmationMessage.removeAttribute('hidden') + // Set tabindex to -1 to make the confirmation banner focusable with JavaScript + if (!confirmationMessage.getAttribute('tabindex')) { + confirmationMessage.setAttribute('tabindex', '-1') - // Set tabindex to -1 to make the confirmation banner focusable with JavaScript - if (!confirmationMessage.getAttribute('tabindex')) { - confirmationMessage.setAttribute('tabindex', '-1') + confirmationMessage.addEventListener('blur', () => { + confirmationMessage.removeAttribute('tabindex') + }) + } - confirmationMessage.addEventListener('blur', () => { - confirmationMessage.removeAttribute('tabindex') - }) + confirmationMessage.focus() } - confirmationMessage.focus() -} - -CookieBanner.prototype.onCookiesPage = function () { - return window.location.pathname === '/cookies/' + onCookiesPage () { + return window.location.pathname === '/cookies/' + } } export default CookieBanner diff --git a/src/javascripts/components/cookies-page.mjs b/src/javascripts/components/cookies-page.mjs index 5035c0178b..c4633d4134 100644 --- a/src/javascripts/components/cookies-page.mjs +++ b/src/javascripts/components/cookies-page.mjs @@ -1,80 +1,82 @@ import { getConsentCookie, setConsentCookie } from './cookie-functions.mjs' -function CookiesPage ($module) { - this.$module = $module -} - -CookiesPage.prototype.init = function () { - this.$cookiePage = this.$module - - if (!this.$cookiePage) { - return +class CookiesPage { + constructor ($module) { + this.$module = $module } - this.$cookieForm = this.$cookiePage.querySelector('.js-cookies-page-form') - this.$cookieFormFieldsets = this.$cookieForm.querySelectorAll('.js-cookies-page-form-fieldset') - this.$successNotification = this.$cookiePage.querySelector('.js-cookies-page-success') + init () { + this.$cookiePage = this.$module - this.$cookieFormFieldsets.forEach(($cookieFormFieldset) => { - this.showUserPreference($cookieFormFieldset, getConsentCookie()) - $cookieFormFieldset.removeAttribute('hidden') - }) + if (!this.$cookiePage) { + return + } - // Show submit button - this.$cookieForm.querySelector('.js-cookies-form-button').removeAttribute('hidden') + this.$cookieForm = this.$cookiePage.querySelector('.js-cookies-page-form') + this.$cookieFormFieldsets = this.$cookieForm.querySelectorAll('.js-cookies-page-form-fieldset') + this.$successNotification = this.$cookiePage.querySelector('.js-cookies-page-success') - this.$cookieForm.addEventListener('submit', (event) => this.savePreferences(event)) -} + this.$cookieFormFieldsets.forEach(($cookieFormFieldset) => { + this.showUserPreference($cookieFormFieldset, getConsentCookie()) + $cookieFormFieldset.removeAttribute('hidden') + }) -CookiesPage.prototype.savePreferences = function (event) { - // Stop default form submission behaviour - event.preventDefault() + // Show submit button + this.$cookieForm.querySelector('.js-cookies-form-button').removeAttribute('hidden') - const preferences = {} + this.$cookieForm.addEventListener('submit', (event) => this.savePreferences(event)) + } - this.$cookieFormFieldsets.forEach(($cookieFormFieldset) => { - const cookieType = this.getCookieType($cookieFormFieldset) - const selectedItem = $cookieFormFieldset.querySelector(`input[name=${cookieType}]:checked`).value + savePreferences (event) { + // Stop default form submission behaviour + event.preventDefault() - preferences[cookieType] = selectedItem === 'yes' - }) + const preferences = {} - // Save preferences to cookie and show success notification - setConsentCookie(preferences) - this.showSuccessNotification() -} + this.$cookieFormFieldsets.forEach(($cookieFormFieldset) => { + const cookieType = this.getCookieType($cookieFormFieldset) + const selectedItem = $cookieFormFieldset.querySelector(`input[name=${cookieType}]:checked`).value -CookiesPage.prototype.showUserPreference = function ($cookieFormFieldset, preferences) { - const cookieType = this.getCookieType($cookieFormFieldset) - let preference = false + preferences[cookieType] = selectedItem === 'yes' + }) - if (cookieType && preferences && preferences[cookieType] !== undefined) { - preference = preferences[cookieType] + // Save preferences to cookie and show success notification + setConsentCookie(preferences) + this.showSuccessNotification() } - const radioValue = preference ? 'yes' : 'no' - const radio = $cookieFormFieldset.querySelector(`input[name=${cookieType}][value=${radioValue}]`) - radio.checked = true -} + showUserPreference ($cookieFormFieldset, preferences) { + const cookieType = this.getCookieType($cookieFormFieldset) + let preference = false -CookiesPage.prototype.showSuccessNotification = function () { - this.$successNotification.removeAttribute('hidden') + if (cookieType && preferences && preferences[cookieType] !== undefined) { + preference = preferences[cookieType] + } - // Set tabindex to -1 to make the element focusable with JavaScript. - // GOV.UK Frontend will remove the tabindex on blur as the component doesn't - // need to be focusable after the user has read the text. - if (!this.$successNotification.getAttribute('tabindex')) { - this.$successNotification.setAttribute('tabindex', '-1') + const radioValue = preference ? 'yes' : 'no' + const radio = $cookieFormFieldset.querySelector(`input[name=${cookieType}][value=${radioValue}]`) + radio.checked = true } - this.$successNotification.focus() + showSuccessNotification () { + this.$successNotification.removeAttribute('hidden') - // scroll to the top of the page - window.scrollTo(0, 0) -} + // Set tabindex to -1 to make the element focusable with JavaScript. + // GOV.UK Frontend will remove the tabindex on blur as the component doesn't + // need to be focusable after the user has read the text. + if (!this.$successNotification.getAttribute('tabindex')) { + this.$successNotification.setAttribute('tabindex', '-1') + } + + this.$successNotification.focus() -CookiesPage.prototype.getCookieType = function ($cookieFormFieldset) { - return $cookieFormFieldset.id + // scroll to the top of the page + window.scrollTo(0, 0) + } + + getCookieType ($cookieFormFieldset) { + return $cookieFormFieldset.id + } } export default CookiesPage diff --git a/src/javascripts/components/copy.mjs b/src/javascripts/components/copy.mjs index bc6e8a57ff..23c089e0c8 100644 --- a/src/javascripts/components/copy.mjs +++ b/src/javascripts/components/copy.mjs @@ -1,39 +1,44 @@ import ClipboardJS from 'clipboard' -function Copy ($module) { - this.$module = $module -} +class Copy { + constructor ($module) { + this.$module = $module + } -Copy.prototype.init = function () { - const $module = this.$module - if (!$module) { - return + init () { + const $module = this.$module + + if (!$module) { + return + } + + const $button = document.createElement('button') + $button.className = 'app-copy-button js-copy-button' + $button.setAttribute('aria-live', 'assertive') + $button.textContent = 'Copy code' + + $module.insertAdjacentElement('beforebegin', $button) + this.copyAction() } - const $button = document.createElement('button') - $button.className = 'app-copy-button js-copy-button' - $button.setAttribute('aria-live', 'assertive') - $button.textContent = 'Copy code' - $module.insertAdjacentElement('beforebegin', $button) - this.copyAction() -} -Copy.prototype.copyAction = function () { - // Copy to clipboard - try { - new ClipboardJS('.js-copy-button', { - target: function (trigger) { - return trigger.nextElementSibling + copyAction () { + // Copy to clipboard + try { + new ClipboardJS('.js-copy-button', { + target: function (trigger) { + return trigger.nextElementSibling + } + }).on('success', function (e) { + e.trigger.textContent = 'Code copied' + e.clearSelection() + setTimeout(() => { + e.trigger.textContent = 'Copy code' + }, 5000) + }) + } catch (err) { + if (err) { + console.log(err.message) } - }).on('success', function (e) { - e.trigger.textContent = 'Code copied' - e.clearSelection() - setTimeout(() => { - e.trigger.textContent = 'Copy code' - }, 5000) - }) - } catch (err) { - if (err) { - console.log(err.message) } } } diff --git a/src/javascripts/components/example-page.mjs b/src/javascripts/components/example-page.mjs index 5b1c1a4db9..c1e38f7c85 100644 --- a/src/javascripts/components/example-page.mjs +++ b/src/javascripts/components/example-page.mjs @@ -1,24 +1,29 @@ -function ExamplePage ($module) { - this.$module = $module -} +class ExamplePage { + constructor ($module) { + this.$module = $module + } + + init () { + const $module = this.$module -ExamplePage.prototype.init = function () { - const $module = this.$module - if (!$module) { - return + if (!$module) { + return + } + + const $form = $module.querySelector('form[action="/form-handler"]') + this.preventFormSubmission($form) } - const $form = $module.querySelector('form[action="/form-handler"]') - this.preventFormSubmission($form) -} -ExamplePage.prototype.preventFormSubmission = function ($form) { - // we should only have one form per example - if (!$form) { - return false + preventFormSubmission ($form) { + // we should only have one form per example + if (!$form) { + return false + } + + $form.addEventListener('submit', (event) => { + event.preventDefault() + }) } - $form.addEventListener('submit', (event) => { - event.preventDefault() - }) } export default ExamplePage diff --git a/src/javascripts/components/example.mjs b/src/javascripts/components/example.mjs index aa9905503c..0f6720c879 100644 --- a/src/javascripts/components/example.mjs +++ b/src/javascripts/components/example.mjs @@ -8,33 +8,37 @@ import iFrameResize from 'iframe-resizer/js/iframeResizer.js' * * @param {Element} $module - HTML element to use for example */ -function Example ($module) { - if (!($module instanceof HTMLIFrameElement)) { - return - } - this.$module = $module -} +class Example { + constructor ($module) { + if (!($module instanceof HTMLIFrameElement)) { + return + } -Example.prototype.init = function () { - const $module = this.$module - if (!$module) { - return + this.$module = $module } - // Initialise asap for eager iframes or browsers which don't support lazy loading - if (!('loading' in $module) || $module.loading !== 'lazy') { - return iFrameResize({ scrolling: 'omit' }, $module) - } + init () { + const $module = this.$module - $module.addEventListener('load', () => { - try { - iFrameResize({ scrolling: 'omit' }, $module) - } catch (err) { - if (err) { - console.error(err.message) - } + if (!$module) { + return } - }) + + // Initialise asap for eager iframes or browsers which don't support lazy loading + if (!('loading' in $module) || $module.loading !== 'lazy') { + return iFrameResize({ scrolling: 'omit' }, $module) + } + + $module.addEventListener('load', () => { + try { + iFrameResize({ scrolling: 'omit' }, $module) + } catch (err) { + if (err) { + console.error(err.message) + } + } + }) + } } export default Example diff --git a/src/javascripts/components/navigation.mjs b/src/javascripts/components/navigation.mjs index 2c9806271a..c69e93a2a6 100644 --- a/src/javascripts/components/navigation.mjs +++ b/src/javascripts/components/navigation.mjs @@ -4,138 +4,139 @@ const subNavActiveClass = 'app-navigation__subnav--active' // This one has the query dot at the beginning because it's only ever used in querySelector calls const subNavJSClass = '.js-app-navigation__subnav' -function Navigation ($module) { - this.$module = $module || document +class Navigation { + constructor ($module) { + this.$module = $module || document - this.$nav = this.$module.querySelector('.js-app-navigation') - this.$navToggler = this.$module.querySelector('.js-app-navigation__toggler') - this.$navButtons = this.$module.querySelectorAll('.js-app-navigation__button') - this.$navLinks = this.$module.querySelectorAll('.js-app-navigation__link') + this.$nav = this.$module.querySelector('.js-app-navigation') + this.$navToggler = this.$module.querySelector('.js-app-navigation__toggler') + this.$navButtons = this.$module.querySelectorAll('.js-app-navigation__button') + this.$navLinks = this.$module.querySelectorAll('.js-app-navigation__link') - // Save the opened/closed state for the nav in memory so that we can accurately maintain state when the screen is changed from small to big and back to small - this.mobileNavOpen = false + // Save the opened/closed state for the nav in memory so that we can accurately maintain state when the screen is changed from small to big and back to small + this.mobileNavOpen = false - // A global const for storing a matchMedia instance which we'll use to detect when a screen size change happens - // We set this later during the init function and rely on it being null if the feature isn't available to initially apply hidden attributes - this.mql = null -} + // A global const for storing a matchMedia instance which we'll use to detect when a screen size change happens + // We set this later during the init function and rely on it being null if the feature isn't available to initially apply hidden attributes + this.mql = null + } -// Checks if the saved window size has changed between now and when it was last recorded (on load and on viewport width changes) -// Reapplies hidden attributes based on if the viewport has changed from big to small or vice verca -// Saves the new window size + // Checks if the saved window size has changed between now and when it was last recorded (on load and on viewport width changes) + // Reapplies hidden attributes based on if the viewport has changed from big to small or vice verca + // Saves the new window size + setHiddenStates () { + if (this.mql === null || !this.mql.matches) { + if (!this.mobileNavOpen) { + this.$nav.setAttribute('hidden', '') + } -Navigation.prototype.setHiddenStates = function () { - if (this.mql === null || !this.mql.matches) { - if (!this.mobileNavOpen) { - this.$nav.setAttribute('hidden', '') - } + this.$navLinks.forEach(($navLink) => { + $navLink.setAttribute('hidden', '') + }) - this.$navLinks.forEach(($navLink) => { - $navLink.setAttribute('hidden', '') - }) + this.$navButtons.forEach(($navButton) => { + $navButton.removeAttribute('hidden') + }) - this.$navButtons.forEach(($navButton) => { - $navButton.removeAttribute('hidden') - }) + this.$navToggler.removeAttribute('hidden') + } else if (this.mql === null || this.mql.matches) { + this.$nav.removeAttribute('hidden') - this.$navToggler.removeAttribute('hidden') - } else if (this.mql === null || this.mql.matches) { - this.$nav.removeAttribute('hidden') + this.$navLinks.forEach(($navLink) => { + $navLink.removeAttribute('hidden') + }) - this.$navLinks.forEach(($navLink) => { - $navLink.removeAttribute('hidden') - }) + this.$navButtons.forEach(($navButton) => { + $navButton.setAttribute('hidden', '') + }) - this.$navButtons.forEach(($navButton) => { - $navButton.setAttribute('hidden', '') - }) - - this.$navToggler.setAttribute('hidden', '') + this.$navToggler.setAttribute('hidden', '') + } } -} -Navigation.prototype.setInitialAriaStates = function () { - this.$navToggler.setAttribute('aria-expanded', 'false') + setInitialAriaStates () { + this.$navToggler.setAttribute('aria-expanded', 'false') - this.$navButtons.forEach(($button, index) => { - const $nextSubNav = $button.parentNode.querySelector(subNavJSClass) + this.$navButtons.forEach(($button, index) => { + const $nextSubNav = $button.parentNode.querySelector(subNavJSClass) - if ($nextSubNav) { - const subNavTogglerId = `js-mobile-nav-subnav-toggler-${index}` - const nextSubNavId = `js-mobile-nav__subnav-${index}` + if ($nextSubNav) { + const subNavTogglerId = `js-mobile-nav-subnav-toggler-${index}` + const nextSubNavId = `js-mobile-nav__subnav-${index}` - $nextSubNav.setAttribute('id', nextSubNavId) - $button.setAttribute('id', subNavTogglerId) - $button.setAttribute('aria-expanded', $nextSubNav.hasAttribute('hidden') ? 'false' : 'true') - $button.setAttribute('aria-controls', nextSubNavId) - } - }) -} + $nextSubNav.setAttribute('id', nextSubNavId) + $button.setAttribute('id', subNavTogglerId) + $button.setAttribute('aria-expanded', $nextSubNav.hasAttribute('hidden') ? 'false' : 'true') + $button.setAttribute('aria-controls', nextSubNavId) + } + }) + } -Navigation.prototype.bindUIEvents = function () { - const $nav = this.$nav - const $navToggler = this.$navToggler - const $navButtons = this.$navButtons + bindUIEvents () { + const $nav = this.$nav + const $navToggler = this.$navToggler + const $navButtons = this.$navButtons - $navToggler.addEventListener('click', () => { - if (this.mobileNavOpen) { - $nav.classList.remove(navActiveClass) - $navToggler.classList.remove(navMenuButtonActiveClass) - $nav.setAttribute('hidden', '') + $navToggler.addEventListener('click', () => { + if (this.mobileNavOpen) { + $nav.classList.remove(navActiveClass) + $navToggler.classList.remove(navMenuButtonActiveClass) + $nav.setAttribute('hidden', '') - $navToggler.setAttribute('aria-expanded', 'false') + $navToggler.setAttribute('aria-expanded', 'false') - this.mobileNavOpen = false - } else { - $nav.classList.add(navActiveClass) - $navToggler.classList.add(navMenuButtonActiveClass) - $nav.removeAttribute('hidden') + this.mobileNavOpen = false + } else { + $nav.classList.add(navActiveClass) + $navToggler.classList.add(navMenuButtonActiveClass) + $nav.removeAttribute('hidden') - $navToggler.setAttribute('aria-expanded', 'true') + $navToggler.setAttribute('aria-expanded', 'true') - this.mobileNavOpen = true - } - }) + this.mobileNavOpen = true + } + }) - $navButtons.forEach(($button) => { - $button.addEventListener('click', () => { - const $nextSubNav = $button.parentNode.querySelector(subNavJSClass) + $navButtons.forEach(($button) => { + $button.addEventListener('click', () => { + const $nextSubNav = $button.parentNode.querySelector(subNavJSClass) - if ($nextSubNav) { - if ($nextSubNav.hasAttribute('hidden')) { - $nextSubNav.classList.add(subNavActiveClass) + if ($nextSubNav) { + if ($nextSubNav.hasAttribute('hidden')) { + $nextSubNav.classList.add(subNavActiveClass) - $nextSubNav.removeAttribute('hidden') - $button.setAttribute('aria-expanded', 'true') - } else { - $nextSubNav.classList.remove(subNavActiveClass) + $nextSubNav.removeAttribute('hidden') + $button.setAttribute('aria-expanded', 'true') + } else { + $nextSubNav.classList.remove(subNavActiveClass) - $nextSubNav.setAttribute('hidden', '') - $button.setAttribute('aria-expanded', 'false') + $nextSubNav.setAttribute('hidden', '') + $button.setAttribute('aria-expanded', 'false') + } } - } + }) }) - }) -} + } -Navigation.prototype.init = function () { - if ('matchMedia' in window) { - // Set the matchMedia to the govuk-frontend tablet breakpoint - this.mql = window.matchMedia('(min-width: 40.0625em)') - - if ('addEventListener' in this.mql) { - this.mql.addEventListener('change', () => this.setHiddenStates()) - } else { - // addListener is a deprecated function, however addEventListener - // isn't supported by Safari < 14. We therefore add this in as - // a fallback for those browsers - this.mql.addListener(() => this.setHiddenStates()) + init () { + if ('matchMedia' in window) { + // Set the matchMedia to the govuk-frontend tablet breakpoint + this.mql = window.matchMedia('(min-width: 40.0625em)') + + if ('addEventListener' in this.mql) { + this.mql.addEventListener('change', () => this.setHiddenStates()) + } else { + // addListener is a deprecated function, however addEventListener + // isn't supported by Safari < 14. We therefore add this in as + // a fallback for those browsers + this.mql.addListener(() => this.setHiddenStates()) + } } - } - this.setHiddenStates() - this.setInitialAriaStates() - this.bindUIEvents() + this.setHiddenStates() + this.setInitialAriaStates() + this.bindUIEvents() + } } export default Navigation diff --git a/src/javascripts/components/options-table.mjs b/src/javascripts/components/options-table.mjs index 993a500c37..78c67c8ac2 100644 --- a/src/javascripts/components/options-table.mjs +++ b/src/javascripts/components/options-table.mjs @@ -1,9 +1,10 @@ -const OptionsTable = { - init: function () { - OptionsTable.expandMacroOptions() - }, +class OptionsTable { + init () { + this.expandMacroOptions() + } + // Open Nunjucks tab and expand macro options details when URL hash is '#options-[exampleName]' - expandMacroOptions: function () { + expandMacroOptions () { const hash = window.location.hash if (hash.match('^#options-')) { diff --git a/src/javascripts/components/search.mjs b/src/javascripts/components/search.mjs index cdf6f291c9..99f681ff75 100644 --- a/src/javascripts/components/search.mjs +++ b/src/javascripts/components/search.mjs @@ -30,156 +30,164 @@ const DEBOUNCE_TIME_TO_WAIT = function () { return (typeof timeout !== 'undefined') ? timeout : 2000 // milliseconds } -function Search ($module) { - this.$module = $module -} +class Search { + constructor ($module) { + this.$module = $module + } -Search.prototype.fetchSearchIndex = function (indexUrl, callback) { - const request = new XMLHttpRequest() - request.open('GET', indexUrl, true) - request.timeout = TIMEOUT * 1000 - statusMessage = 'Loading search index' - request.onreadystatechange = function () { - if (request.readyState === STATE_DONE) { - if (request.status === 200) { - const response = request.responseText - const json = JSON.parse(response) - statusMessage = 'No results found' - searchIndex = lunr.Index.load(json.index) - documentStore = json.store - callback(json) - } else { - statusMessage = 'Failed to load the search index' + fetchSearchIndex (indexUrl, callback) { + const request = new XMLHttpRequest() + request.open('GET', indexUrl, true) + request.timeout = TIMEOUT * 1000 + statusMessage = 'Loading search index' + + request.onreadystatechange = function () { + if (request.readyState === STATE_DONE) { + if (request.status === 200) { + const response = request.responseText + const json = JSON.parse(response) + statusMessage = 'No results found' + searchIndex = lunr.Index.load(json.index) + documentStore = json.store + callback(json) + } else { + statusMessage = 'Failed to load the search index' + } } } - } - request.send() -} -Search.prototype.renderResults = function () { - if (!searchIndex || !documentStore) { - return searchCallback(searchResults) + request.send() } - const lunrSearchResults = searchIndex.query((q) => { - q.term(lunr.tokenizer(searchQuery), { - wildcard: lunr.Query.wildcard.TRAILING + + renderResults () { + if (!searchIndex || !documentStore) { + return searchCallback(searchResults) + } + + const lunrSearchResults = searchIndex.query((q) => { + q.term(lunr.tokenizer(searchQuery), { + wildcard: lunr.Query.wildcard.TRAILING + }) }) - }) - searchResults = lunrSearchResults.map((result) => { - return documentStore[result.ref] - }) - searchCallback(searchResults) -} -Search.prototype.handleSearchQuery = function (query, callback) { - searchQuery = query - searchCallback = callback + searchResults = lunrSearchResults.map((result) => { + return documentStore[result.ref] + }) + searchCallback(searchResults) + } - clearTimeout(inputDebounceTimer) - inputDebounceTimer = setTimeout(() => { - trackSearchResults(searchQuery, searchResults) - }, DEBOUNCE_TIME_TO_WAIT()) + handleSearchQuery (query, callback) { + searchQuery = query + searchCallback = callback - this.renderResults() -} + clearTimeout(inputDebounceTimer) + inputDebounceTimer = setTimeout(() => { + trackSearchResults(searchQuery, searchResults) + }, DEBOUNCE_TIME_TO_WAIT()) -Search.prototype.handleOnConfirm = function (result) { - const permalink = result.permalink - if (!permalink) { - return + this.renderResults() } - trackConfirm(searchQuery, searchResults, result) - window.location.href = `/${permalink}` -} -Search.prototype.inputValueTemplate = function (result) { - if (result) { - return result.title + handleOnConfirm (result) { + const permalink = result.permalink + + if (!permalink) { + return + } + + trackConfirm(searchQuery, searchResults, result) + window.location.href = `/${permalink}` } -} -Search.prototype.resultTemplate = function (result) { - function startsWithFilter (words, query) { - return words.filter((word) => { - return word.trim().toLowerCase().indexOf(query.toLowerCase()) === 0 - }) + inputValueTemplate (result) { + if (result) { + return result.title + } } - // Add rest of the data here to build the item - if (result) { - const elem = document.createElement('span') - elem.textContent = result.title + resultTemplate (result) { + function startsWithFilter (words, query) { + return words.filter((word) => { + return word.trim().toLowerCase().indexOf(query.toLowerCase()) === 0 + }) + } - // Title split into words - const words = result.title.match(/\w+/g) || [] + // Add rest of the data here to build the item + if (result) { + const elem = document.createElement('span') + elem.textContent = result.title - // Title words that start with the query - const matchedWords = startsWithFilter(words, searchQuery) + // Title split into words + const words = result.title.match(/\w+/g) || [] - // Only show a matching alias if there are no matches in the title - if (!matchedWords.length && result.aliases) { - const aliases = result.aliases.split(', ') + // Title words that start with the query + const matchedWords = startsWithFilter(words, searchQuery) - // Aliases containing words that start with the query - const matchedAliases = aliases.reduce((aliasesFiltered, alias) => { - const aliasWordsMatched = startsWithFilter(alias.match(/\w+/g) || [], searchQuery) + // Only show a matching alias if there are no matches in the title + if (!matchedWords.length && result.aliases) { + const aliases = result.aliases.split(', ') - return aliasWordsMatched.length - ? aliasesFiltered.concat([alias]) - : aliasesFiltered - }, []) + // Aliases containing words that start with the query + const matchedAliases = aliases.reduce((aliasesFiltered, alias) => { + const aliasWordsMatched = startsWithFilter(alias.match(/\w+/g) || [], searchQuery) - if (matchedAliases.length) { - const aliasesContainer = document.createElement('span') - aliasesContainer.className = 'app-site-search__aliases' - aliasesContainer.textContent = matchedAliases.join(', ') - elem.appendChild(aliasesContainer) + return aliasWordsMatched.length + ? aliasesFiltered.concat([alias]) + : aliasesFiltered + }, []) + + if (matchedAliases.length) { + const aliasesContainer = document.createElement('span') + aliasesContainer.className = 'app-site-search__aliases' + aliasesContainer.textContent = matchedAliases.join(', ') + elem.appendChild(aliasesContainer) + } } - } - const section = document.createElement('span') - section.className = 'app-site-search--section' - section.innerHTML = result.section + const section = document.createElement('span') + section.className = 'app-site-search--section' + section.innerHTML = result.section - elem.appendChild(section) - return elem.innerHTML + elem.appendChild(section) + return elem.innerHTML + } } -} -Search.prototype.init = function () { - const $module = this.$module - if (!$module) { - return - } + init () { + const $module = this.$module + if (!$module) { + return + } - accessibleAutocomplete({ - element: $module, - id: 'app-site-search__input', - cssNamespace: 'app-site-search', - displayMenu: 'overlay', - placeholder: 'Search Design System', - confirmOnBlur: false, - autoselect: true, - source: this.handleSearchQuery.bind(this), - onConfirm: this.handleOnConfirm, - templates: { - inputValue: this.inputValueTemplate, - suggestion: this.resultTemplate - }, - tNoResults: function () { return statusMessage } - }) - - const $input = $module.querySelector('.app-site-search__input') - - // Ensure if the user stops using the search that we do not send tracking events - $input.addEventListener('blur', () => { - clearTimeout(inputDebounceTimer) - }) + accessibleAutocomplete({ + element: $module, + id: 'app-site-search__input', + cssNamespace: 'app-site-search', + displayMenu: 'overlay', + placeholder: 'Search Design System', + confirmOnBlur: false, + autoselect: true, + source: this.handleSearchQuery.bind(this), + onConfirm: this.handleOnConfirm, + templates: { + inputValue: this.inputValueTemplate, + suggestion: this.resultTemplate + }, + tNoResults: function () { return statusMessage } + }) - const searchIndexUrl = $module.getAttribute('data-search-index') - this.fetchSearchIndex(searchIndexUrl, () => { - this.renderResults() - }) + const $input = $module.querySelector('.app-site-search__input') + + // Ensure if the user stops using the search that we do not send tracking events + $input.addEventListener('blur', () => { + clearTimeout(inputDebounceTimer) + }) + + const searchIndexUrl = $module.getAttribute('data-search-index') + this.fetchSearchIndex(searchIndexUrl, () => { + this.renderResults() + }) + } } export default Search diff --git a/src/javascripts/components/tabs.mjs b/src/javascripts/components/tabs.mjs index 0dd03783ef..60c6ae1f82 100644 --- a/src/javascripts/components/tabs.mjs +++ b/src/javascripts/components/tabs.mjs @@ -9,160 +9,162 @@ * - panels - the content that is shown/hidden/switched; same across all breakpoints */ -function AppTabs ($module) { - this.$module = $module - this.$mobileTabs = this.$module.querySelectorAll('.js-tabs__heading a') - this.$desktopTabs = this.$module.querySelectorAll('.js-tabs__item a') - this.$panels = this.$module.querySelectorAll('.js-tabs__container') -} - -AppTabs.prototype.init = function () { - // Exit if no module has been defined - if (!this.$module) { - return +class AppTabs { + constructor ($module) { + this.$module = $module + this.$mobileTabs = this.$module.querySelectorAll('.js-tabs__heading a') + this.$desktopTabs = this.$module.querySelectorAll('.js-tabs__item a') + this.$panels = this.$module.querySelectorAll('.js-tabs__container') } - // Enhance mobile tabs into buttons - this.enhanceMobileTabs() + init () { + // Exit if no module has been defined + if (!this.$module) { + return + } + + // Enhance mobile tabs into buttons + this.enhanceMobileTabs() - // Add bindings to desktop tabs - this.$desktopTabs.forEach(($tab) => { - $tab.addEventListener('click', (event) => this.onClick(event)) - }) + // Add bindings to desktop tabs + this.$desktopTabs.forEach(($tab) => { + $tab.addEventListener('click', (event) => this.onClick(event)) + }) - // Reset all tabs and panels to closed state - // We also add all our default ARIA goodness here - this.resetTabs() + // Reset all tabs and panels to closed state + // We also add all our default ARIA goodness here + this.resetTabs() - // Show the first panel already open if the `open` attribute is present - if (this.$module.hasAttribute('data-open')) { - this.openPanel(this.$panels[0].id) + // Show the first panel already open if the `open` attribute is present + if (this.$module.hasAttribute('data-open')) { + this.openPanel(this.$panels[0].id) + } } -} -/** - * - */ -AppTabs.prototype.onClick = function (event) { - event.preventDefault() - const $currentTab = event.target - const panelId = $currentTab.getAttribute('aria-controls') - const $panel = this.getPanel(panelId) - const isTabAlreadyOpen = $currentTab.getAttribute('aria-expanded') === 'true' - - if (!$panel) { - throw new Error(`Invalid example ID given: ${panelId}`) + /** + * + */ + onClick (event) { + event.preventDefault() + const $currentTab = event.target + const panelId = $currentTab.getAttribute('aria-controls') + const $panel = this.getPanel(panelId) + const isTabAlreadyOpen = $currentTab.getAttribute('aria-expanded') === 'true' + + if (!$panel) { + throw new Error(`Invalid example ID given: ${panelId}`) + } + + // If the panel that's been called is already open, close it. + // Otherwise, close all panels and open the one requested. + if (isTabAlreadyOpen) { + this.closePanel(panelId) + } else { + this.resetTabs() + this.openPanel(panelId) + } } - // If the panel that's been called is already open, close it. - // Otherwise, close all panels and open the one requested. - if (isTabAlreadyOpen) { - this.closePanel(panelId) - } else { - this.resetTabs() - this.openPanel(panelId) + /** + * Enhances mobile tab anchors to buttons elements + * + * On mobile, tabs act like an accordion and are semantically more similar to + * buttons than links, so let's use the appropriate element + */ + enhanceMobileTabs () { + // Loop through mobile tabs... + this.$mobileTabs.forEach(($tab) => { + // ...construct a button equivalent of each anchor... + const $button = document.createElement('button') + $button.setAttribute('aria-controls', $tab.getAttribute('aria-controls')) + $button.setAttribute('data-track', $tab.getAttribute('data-track')) + $button.classList.add('app-tabs__heading-button') + $button.innerHTML = $tab.innerHTML + // ...bind controls... + $button.addEventListener('click', (event) => this.onClick(event)) + // ...and replace the anchor with the button + $tab.parentNode.appendChild($button) + $tab.parentNode.removeChild($tab) + }) + + // Replace the value of $mobileTabs with the new buttons + this.$mobileTabs = this.$module.querySelectorAll('.js-tabs__heading button') } -} -/** - * Enhances mobile tab anchors to buttons elements - * - * On mobile, tabs act like an accordion and are semantically more similar to - * buttons than links, so let's use the appropriate element - */ -AppTabs.prototype.enhanceMobileTabs = function () { - // Loop through mobile tabs... - this.$mobileTabs.forEach(($tab) => { - // ...construct a button equivalent of each anchor... - const $button = document.createElement('button') - $button.setAttribute('aria-controls', $tab.getAttribute('aria-controls')) - $button.setAttribute('data-track', $tab.getAttribute('data-track')) - $button.classList.add('app-tabs__heading-button') - $button.innerHTML = $tab.innerHTML - // ...bind controls... - $button.addEventListener('click', (event) => this.onClick(event)) - // ...and replace the anchor with the button - $tab.parentNode.appendChild($button) - $tab.parentNode.removeChild($tab) - }) - - // Replace the value of $mobileTabs with the new buttons - this.$mobileTabs = this.$module.querySelectorAll('.js-tabs__heading button') -} + /** + * Reset tabs and panels to closed state + */ + resetTabs () { + this.$panels.forEach(($panel) => { + // We don't want to hide the panel if there are no tabs present to show it + if (!$panel.classList.contains('js-tabs__container--no-tabs')) { + this.closePanel($panel.id) + } + }) + } -/** - * Reset tabs and panels to closed state - */ -AppTabs.prototype.resetTabs = function () { - this.$panels.forEach(($panel) => { - // We don't want to hide the panel if there are no tabs present to show it - if (!$panel.classList.contains('js-tabs__container--no-tabs')) { - this.closePanel($panel.id) + /** + * Open a panel and set the associated controls and styles + */ + openPanel (panelId) { + const $mobileTab = this.getMobileTab(panelId) + const $desktopTab = this.getDesktopTab(panelId) + + // Panels can exist without associated tabs—for example if there's a single + // panel that's open by default—so make sure they actually exist before use + if ($mobileTab && $desktopTab) { + $mobileTab.setAttribute('aria-expanded', 'true') + $mobileTab.parentNode.classList.add('app-tabs__heading--current') + $desktopTab.setAttribute('aria-expanded', 'true') + $desktopTab.parentNode.classList.add('app-tabs__item--current') } - }) -} -/** - * Open a panel and set the associated controls and styles - */ -AppTabs.prototype.openPanel = function (panelId) { - const $mobileTab = this.getMobileTab(panelId) - const $desktopTab = this.getDesktopTab(panelId) - - // Panels can exist without associated tabs—for example if there's a single - // panel that's open by default—so make sure they actually exist before use - if ($mobileTab && $desktopTab) { - $mobileTab.setAttribute('aria-expanded', 'true') - $mobileTab.parentNode.classList.add('app-tabs__heading--current') - $desktopTab.setAttribute('aria-expanded', 'true') - $desktopTab.parentNode.classList.add('app-tabs__item--current') + this.getPanel(panelId).removeAttribute('hidden') } - this.getPanel(panelId).removeAttribute('hidden') -} + /** + * Close a panel and set the associated controls and styles + */ + closePanel (panelId) { + const $mobileTab = this.getMobileTab(panelId) + const $desktopTab = this.getDesktopTab(panelId) + $mobileTab.setAttribute('aria-expanded', 'false') + $desktopTab.setAttribute('aria-expanded', 'false') + $mobileTab.parentNode.classList.remove('app-tabs__heading--current') + $desktopTab.parentNode.classList.remove('app-tabs__item--current') + this.getPanel(panelId).setAttribute('hidden', 'hidden') + } -/** - * Close a panel and set the associated controls and styles - */ -AppTabs.prototype.closePanel = function (panelId) { - const $mobileTab = this.getMobileTab(panelId) - const $desktopTab = this.getDesktopTab(panelId) - $mobileTab.setAttribute('aria-expanded', 'false') - $desktopTab.setAttribute('aria-expanded', 'false') - $mobileTab.parentNode.classList.remove('app-tabs__heading--current') - $desktopTab.parentNode.classList.remove('app-tabs__item--current') - this.getPanel(panelId).setAttribute('hidden', 'hidden') -} + /** + * Helper function to get a specific mobile tab by the associated panel ID + */ + getMobileTab (panelId) { + let result = null + this.$mobileTabs.forEach(($tab) => { + if ($tab.getAttribute('aria-controls') === panelId) { + result = $tab + } + }) + return result + } -/** - * Helper function to get a specific mobile tab by the associated panel ID - */ -AppTabs.prototype.getMobileTab = function (panelId) { - let result = null - this.$mobileTabs.forEach(($tab) => { - if ($tab.getAttribute('aria-controls') === panelId) { - result = $tab + /** + * Helper function to get a specific desktop tab by the associated panel ID + */ + getDesktopTab (panelId) { + const $desktopTabContainer = this.$module.querySelector('.app-tabs') + if ($desktopTabContainer) { + return $desktopTabContainer.querySelector(`[aria-controls="${panelId}"]`) } - }) - return result -} - -/** - * Helper function to get a specific desktop tab by the associated panel ID - */ -AppTabs.prototype.getDesktopTab = function (panelId) { - const $desktopTabContainer = this.$module.querySelector('.app-tabs') - if ($desktopTabContainer) { - return $desktopTabContainer.querySelector(`[aria-controls="${panelId}"]`) + return null } - return null -} -/** - * Helper function to get a specific panel by ID - */ -AppTabs.prototype.getPanel = function (panelId) { - return document.getElementById(panelId) + /** + * Helper function to get a specific panel by ID + */ + getPanel (panelId) { + return document.getElementById(panelId) + } } export default AppTabs From 67e8153f382ec96732b56911b1ec4d8d616e77c4 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 20 Jul 2023 07:55:58 +0100 Subject: [PATCH 2/4] Enable ESLint JSDoc checks --- .eslintrc.js | 40 ++++++++ package-lock.json | 249 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 3 files changed, 284 insertions(+), 6 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 996c772e22..8359833479 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,7 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:import/recommended', + 'plugin:jsdoc/recommended-typescript-flavor', 'plugin:n/recommended', 'plugin:promise/recommended' ], @@ -22,6 +23,7 @@ module.exports = { }, plugins: [ 'import', + 'jsdoc', 'n', 'promise' ], @@ -35,9 +37,47 @@ module.exports = { } ], + // JSDoc blocks are optional + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-param': 'off', + + // Check for valid formatting + 'jsdoc/check-line-alignment': [ + 'warn', + 'never', { + wrapIndent: ' ' + } + ], + + // Add unknown @jest-environment tag name + 'jsdoc/check-tag-names': [ + 'warn', { + definedTags: ['jest-environment'] + } + ], + + // Require hyphens before param description + // Aligns with TSDoc style: https://tsdoc.org/pages/tags/param/ + 'jsdoc/require-hyphen-before-param-description': 'warn', + + // Maintain new line after description + 'jsdoc/tag-lines': [ + 'warn', + 'never', { + startLines: 1 + } + ], + // Automatically use template strings 'no-useless-concat': 'error', 'prefer-template': 'error' + }, + settings: { + jsdoc: { + // Allows us to use type declarations that exist in our dependencies + mode: 'typescript' + } } }, { diff --git a/package-lock.json b/package-lock.json index 6c45005f72..a42d6a954c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "eslint": "^8.45.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsdoc": "^46.4.4", "eslint-plugin-n": "^16.0.1", "eslint-plugin-promise": "^6.1.1", "glob": "^10.3.3", @@ -2009,6 +2010,20 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.39.4", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.39.4.tgz", + "integrity": "sha512-Jvw915fjqQct445+yron7Dufix9A+m9j1fCJYlCo1FWlRvTxa3pjJelxdSTdaLWcTwRU6vbL+NYjO4YuNIS5Qg==", + "dev": true, + "dependencies": { + "comment-parser": "1.3.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3503,6 +3518,15 @@ "node": ">= 8" } }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5110,6 +5134,15 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/comment-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz", + "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/common-path-prefix": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-1.0.0.tgz", @@ -6427,6 +6460,95 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-jsdoc": { + "version": "46.4.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.4.4.tgz", + "integrity": "sha512-D8TGPOkq3bnzmYmA7Q6jdsW+Slx7CunhJk1tlouVq6wJjlP1p6eigZPvxFn7aufud/D66xBsNVMhkDQEuqumMg==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.39.4", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.3.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.5.1", + "spdx-expression-parse": "^3.0.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/eslint-plugin-jsdoc/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/spdx-license-ids": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", + "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", + "dev": true + }, "node_modules/eslint-plugin-n": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-16.0.1.tgz", @@ -6779,9 +6901,9 @@ "dev": true }, "node_modules/esquery": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.2.tgz", - "integrity": "sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, "dependencies": { "estraverse": "^5.1.0" @@ -9887,6 +10009,15 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/jsdom": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", @@ -13911,6 +14042,12 @@ "spdx-license-ids": "^1.0.2" } }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, "node_modules/spdx-expression-parse": { "version": "1.0.4", "dev": true, @@ -17412,6 +17549,17 @@ "dev": true, "requires": {} }, + "@es-joy/jsdoccomment": { + "version": "0.39.4", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.39.4.tgz", + "integrity": "sha512-Jvw915fjqQct445+yron7Dufix9A+m9j1fCJYlCo1FWlRvTxa3pjJelxdSTdaLWcTwRU6vbL+NYjO4YuNIS5Qg==", + "dev": true, + "requires": { + "comment-parser": "1.3.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + } + }, "@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -18588,6 +18736,12 @@ "picomatch": "^2.0.4" } }, + "are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true + }, "arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -19807,6 +19961,12 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "comment-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.3.1.tgz", + "integrity": "sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==", + "dev": true + }, "common-path-prefix": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-1.0.0.tgz", @@ -20880,6 +21040,71 @@ } } }, + "eslint-plugin-jsdoc": { + "version": "46.4.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.4.4.tgz", + "integrity": "sha512-D8TGPOkq3bnzmYmA7Q6jdsW+Slx7CunhJk1tlouVq6wJjlP1p6eigZPvxFn7aufud/D66xBsNVMhkDQEuqumMg==", + "dev": true, + "requires": { + "@es-joy/jsdoccomment": "~0.39.4", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.3.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.5.1", + "spdx-expression-parse": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", + "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", + "dev": true + } + } + }, "eslint-plugin-n": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-16.0.1.tgz", @@ -21012,9 +21237,9 @@ "dev": true }, "esquery": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.2.tgz", - "integrity": "sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, "requires": { "estraverse": "^5.1.0" @@ -23265,6 +23490,12 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, + "jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true + }, "jsdom": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", @@ -26222,6 +26453,12 @@ "spdx-license-ids": "^1.0.2" } }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, "spdx-expression-parse": { "version": "1.0.4", "dev": true diff --git a/package.json b/package.json index c82ec12875..4e40b22a01 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "eslint": "^8.45.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsdoc": "^46.4.4", "eslint-plugin-n": "^16.0.1", "eslint-plugin-promise": "^6.1.1", "glob": "^10.3.3", From 28230f14f7f0bb12476b4dbbd126dbbfdb083037 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 20 Jul 2023 08:15:30 +0100 Subject: [PATCH 3/4] Fix invalid or required JSDoc blocks --- lib/colours.js | 5 +- lib/fingerprints/index.js | 1 + lib/get-macro-options/index.js | 3 +- lib/marked-renderer.js | 4 + lib/metalsmith-title-checker.js | 170 +++++++++--------- .../components/cookie-functions.mjs | 57 +++++- src/javascripts/components/cookies-page.mjs | 23 ++- src/javascripts/components/copy.mjs | 6 +- src/javascripts/components/example-page.mjs | 11 +- src/javascripts/components/example.mjs | 12 +- src/javascripts/components/navigation.mjs | 24 ++- src/javascripts/components/options-table.mjs | 36 ++-- src/javascripts/components/search.mjs | 9 +- src/javascripts/components/tabs.mjs | 15 ++ 14 files changed, 223 insertions(+), 153 deletions(-) diff --git a/lib/colours.js b/lib/colours.js index 523e4ee114..a6f17ea56b 100644 --- a/lib/colours.js +++ b/lib/colours.js @@ -21,7 +21,7 @@ function getColourFromSass (sass, variableName) { * Extracts the colour palette from the $govuk-colours map defined in * settings/_colours-palette.scss in GOV.UK Frontend * - * @return Object mapping colour names to their hexadecimal values + * @returns Object mapping colour names to their hexadecimal values */ const palette = function () { @@ -58,9 +58,8 @@ const palette = function () { * } * ``` * - * @return Object containing 'groups' of colours + * @returns {{ [color: string]: { name: string, notes?: string }[] }} Groups of colours */ - const applied = function () { const data = require('../data/colours.json') const sass = parseSCSS('_colours-applied.scss') diff --git a/lib/fingerprints/index.js b/lib/fingerprints/index.js index 34a4d0c6b4..be75569c0a 100644 --- a/lib/fingerprints/index.js +++ b/lib/fingerprints/index.js @@ -67,6 +67,7 @@ const hashAssets = (config) => (files, metalsmith, done) => { * * @param {import('metalsmith').Files} files - Metalsmith files * @param {import('metalsmith')} metalsmith - Metalsmith builder + * @returns {(pathAsset: string) => { path: string; hash: string; }} File hasher */ const hashAsset = (files, metalsmith) => { const metadata = metalsmith.metadata() diff --git a/lib/get-macro-options/index.js b/lib/get-macro-options/index.js index 573c44db93..4a38dc67fd 100644 --- a/lib/get-macro-options/index.js +++ b/lib/get-macro-options/index.js @@ -140,7 +140,8 @@ function getAdditionalComponentOptions (options) { * } * ``` * - * */ + * @returns {{ name: string, id: string, options: unknown[] }} Macro options + */ function getMacroOptions (componentName) { // The design system uses a different name for the input component. if (componentName === 'text-input') { diff --git a/lib/marked-renderer.js b/lib/marked-renderer.js index 3853826e97..e1d7472774 100644 --- a/lib/marked-renderer.js +++ b/lib/marked-renderer.js @@ -15,6 +15,8 @@ class DesignSystemRenderer extends marked.Renderer { /** * Assume HTML when no code block language provided * (for example, HTML code inside Markdown) + * + * @returns {string} Code block */ code (code, language, isEscaped) { return !language @@ -28,6 +30,8 @@ class DesignSystemRenderer extends marked.Renderer { /** * Avoid wrapping `` image tags in `

` paragraphs + * + * @returns {string} Paragraph */ paragraph (text) { return text.trim().startsWith(' { - const { title } = files[filename] - // If an title exists and is not the current index it means it is a duplicate. - if (!groupedFiles[title]) { - groupedFiles[title] = [] - } - groupedFiles[title].push({ - // Add the filename to the file object for convenience - __filename: filename, - ...files[filename] - }) - }) - - return groupedFiles -} - -function getDuplicateTitles (groupedFiles) { - const duplicateGroupedTitles = {} - - for (const title in groupedFiles) { - const group = groupedFiles[title] - - const groupedWithoutExcludedFiles = - group - // Default examples always have a duplicated title, - // this is OK since the iframes are appended with 'example'. - .filter(file => !file.__filename.includes('/default/')) - // Filter out Windows default paths too: - .filter(file => !file.__filename.includes('\\default\\')) - // Code examples always have a duplicated title, - // as it's only used for the code example not the iframe. - .filter(file => !file.__filename.endsWith('code.njk')) - - if (groupedWithoutExcludedFiles.length > 1) { - duplicateGroupedTitles[title] = groupedWithoutExcludedFiles - } - } - - return duplicateGroupedTitles -} - -module.exports = function titleChecker () { - return (files, metalsmith, done) => { - const nunjucksFileNames = - Object - .keys(files) - // Filter files that are Nunjucks, which have frontmatter. - .filter(filename => filename.endsWith('.njk')) - - const groupedFilesByTitle = groupFilesByTitle(nunjucksFileNames, files) - const filesWithDuplicateTitles = getDuplicateTitles(groupedFilesByTitle) - - if (Object.keys(filesWithDuplicateTitles).length) { - let duplicateErrorMessage = '' - for (const title in filesWithDuplicateTitles) { - const group = filesWithDuplicateTitles[title] - duplicateErrorMessage += `The title "${title}" is duplicated ${group.length} times in the following file(s):\n` - duplicateErrorMessage += group.map(file => `- ${file.__filename}`).join('\n') - duplicateErrorMessage += '\n\n' - } - throw Error(duplicateErrorMessage) - } - - const filesWithoutTitles = nunjucksFileNames.filter(filename => !files[filename].title) - - if (filesWithoutTitles.length) { - throw Error( - `The following file(s) do not have titles:\n\n${ - filesWithoutTitles.map(file => `- ${file}`).join('\n') - }\n` - ) - } - done() - } -} +/** + * Metalsmith plugin to check each page has a unique title. + * Which is required to make sure users of assistive technologies can navigate easily. + */ + +function groupFilesByTitle (filenames, files) { + const groupedFiles = {} + + filenames + .forEach((filename, index) => { + const { title } = files[filename] + // If an title exists and is not the current index it means it is a duplicate. + if (!groupedFiles[title]) { + groupedFiles[title] = [] + } + groupedFiles[title].push({ + // Add the filename to the file object for convenience + __filename: filename, + ...files[filename] + }) + }) + + return groupedFiles +} + +function getDuplicateTitles (groupedFiles) { + const duplicateGroupedTitles = {} + + for (const title in groupedFiles) { + const group = groupedFiles[title] + + const groupedWithoutExcludedFiles = + group + // Default examples always have a duplicated title, + // this is OK since the iframes are appended with 'example'. + .filter(file => !file.__filename.includes('/default/')) + // Filter out Windows default paths too: + .filter(file => !file.__filename.includes('\\default\\')) + // Code examples always have a duplicated title, + // as it's only used for the code example not the iframe. + .filter(file => !file.__filename.endsWith('code.njk')) + + if (groupedWithoutExcludedFiles.length > 1) { + duplicateGroupedTitles[title] = groupedWithoutExcludedFiles + } + } + + return duplicateGroupedTitles +} + +module.exports = function titleChecker () { + return (files, metalsmith, done) => { + const nunjucksFileNames = + Object + .keys(files) + // Filter files that are Nunjucks, which have frontmatter. + .filter(filename => filename.endsWith('.njk')) + + const groupedFilesByTitle = groupFilesByTitle(nunjucksFileNames, files) + const filesWithDuplicateTitles = getDuplicateTitles(groupedFilesByTitle) + + if (Object.keys(filesWithDuplicateTitles).length) { + let duplicateErrorMessage = '' + for (const title in filesWithDuplicateTitles) { + const group = filesWithDuplicateTitles[title] + duplicateErrorMessage += `The title "${title}" is duplicated ${group.length} times in the following file(s):\n` + duplicateErrorMessage += group.map(file => `- ${file.__filename}`).join('\n') + duplicateErrorMessage += '\n\n' + } + throw Error(duplicateErrorMessage) + } + + const filesWithoutTitles = nunjucksFileNames.filter(filename => !files[filename].title) + + if (filesWithoutTitles.length) { + throw Error( + `The following file(s) do not have titles:\n\n${ + filesWithoutTitles.map(file => `- ${file}`).join('\n') + }\n` + ) + } + done() + } +} diff --git a/src/javascripts/components/cookie-functions.mjs b/src/javascripts/components/cookie-functions.mjs index a47e3d1389..89a484713f 100644 --- a/src/javascripts/components/cookie-functions.mjs +++ b/src/javascripts/components/cookie-functions.mjs @@ -44,11 +44,9 @@ const DEFAULT_COOKIE_CONSENT = { analytics: false } -/* +/** * Set, get, and delete cookies. * - * Usage: - * * Setting a cookie: * Cookie('hobnob', 'tasty', { days: 30 }) * @@ -57,6 +55,11 @@ const DEFAULT_COOKIE_CONSENT = { * * Deleting a cookie: * Cookie('hobnob', null) + * + * @param {string} name - Cookie name + * @param {string | false} [value] - Cookie value + * @param {{ days?: number }} [options] - Cookie options + * @returns {string | void} - Returns value when setting or deleting */ export function Cookie (name, value, options) { if (typeof value !== 'undefined') { @@ -74,10 +77,13 @@ export function Cookie (name, value, options) { } } -/** Return the user's cookie preferences. +/** + * Return the user's cookie preferences. * * If the consent cookie is malformed, or not present, * returns null. + * + * @returns {ConsentPreferences | null} Consent preferences */ export function getConsentCookie () { const consentCookie = getCookie(CONSENT_COOKIE_NAME) @@ -96,18 +102,26 @@ export function getConsentCookie () { return consentCookieObj } -/** Check the cookie preferences object. +/** + * Check the cookie preferences object. * * If the consent object is not present, malformed, or incorrect version, * returns false, otherwise returns true. * * This is also duplicated in cookie-banner.njk - the two need to be kept in sync + * + * @param {ConsentPreferences} options - Consent preferences + * @returns {boolean} True if consent cookie is valid */ export function isValidConsentCookie (options) { return (options && options.version >= window.GDS_CONSENT_COOKIE_VERSION) } -/** Update the user's cookie preferences. */ +/** + * Update the user's cookie preferences. + * + * @param {ConsentPreferences} options - Consent options to parse + */ export function setConsentCookie (options) { let cookieConsent = getConsentCookie() @@ -132,7 +146,8 @@ export function setConsentCookie (options) { resetCookies() } -/** Apply the user's cookie preferences +/** + * Apply the user's cookie preferences * * Deletes any cookies the user has not consented to. */ @@ -178,6 +193,11 @@ export function resetCookies () { } } +/** + * @param {string} cookieCategory - Cookie type + * @param {ConsentPreferences} cookiePreferences - Consent preferences + * @returns {string | boolean} Cookie type value + */ function userAllowsCookieCategory (cookieCategory, cookiePreferences) { // Essential cookies are always allowed if (cookieCategory === 'essential') { @@ -193,6 +213,10 @@ function userAllowsCookieCategory (cookieCategory, cookiePreferences) { } } +/** + * @param {string} cookieName - Cookie name + * @returns {string | boolean} Cookie type value + */ function userAllowsCookie (cookieName) { // Always allow setting the consent cookie if (cookieName === CONSENT_COOKIE_NAME) { @@ -219,6 +243,10 @@ function userAllowsCookie (cookieName) { return false } +/** + * @param {string} name - Cookie name + * @returns {string} Cookie value + */ function getCookie (name) { const nameEQ = `${name}=` const cookies = document.cookie.split(';') @@ -234,6 +262,11 @@ function getCookie (name) { return null } +/** + * @param {string} name - Cookie name + * @param {string} value - Cookie value + * @param {{ days?: number }} [options] - Cookie options + */ function setCookie (name, value, options) { if (userAllowsCookie(name)) { if (typeof options === 'undefined') { @@ -252,6 +285,9 @@ function setCookie (name, value, options) { } } +/** + * @param {string} name - Cookie name + */ function deleteCookie (name) { if (Cookie(name)) { // Cookies need to be deleted in the same level of specificity in which they were set @@ -263,3 +299,10 @@ function deleteCookie (name) { document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;domain=.${window.location.hostname};path=/` } } + +/** + * @typedef {object} ConsentPreferences + * @property {boolean} [analytics] - Accept analytics cookies + * @property {boolean} [essential] - Accept essential cookies + * @property {string} [version] - Content cookie version + */ diff --git a/src/javascripts/components/cookies-page.mjs b/src/javascripts/components/cookies-page.mjs index c4633d4134..14b8c72816 100644 --- a/src/javascripts/components/cookies-page.mjs +++ b/src/javascripts/components/cookies-page.mjs @@ -27,6 +27,9 @@ class CookiesPage { this.$cookieForm.addEventListener('submit', (event) => this.savePreferences(event)) } + /** + * @param {SubmitEvent} event - Form submit event + */ savePreferences (event) { // Stop default form submission behaviour event.preventDefault() @@ -35,9 +38,11 @@ class CookiesPage { this.$cookieFormFieldsets.forEach(($cookieFormFieldset) => { const cookieType = this.getCookieType($cookieFormFieldset) - const selectedItem = $cookieFormFieldset.querySelector(`input[name=${cookieType}]:checked`).value + const $selectedItem = $cookieFormFieldset.querySelector(`input[name=${cookieType}]:checked`) - preferences[cookieType] = selectedItem === 'yes' + if ($selectedItem instanceof HTMLInputElement) { + preferences[cookieType] = $selectedItem.value === 'yes' + } }) // Save preferences to cookie and show success notification @@ -45,6 +50,10 @@ class CookiesPage { this.showSuccessNotification() } + /** + * @param {HTMLFieldSetElement} $cookieFormFieldset - Cookie form fieldset + * @param {import('./cookie-functions.mjs').ConsentPreferences} preferences - Consent preferences + */ showUserPreference ($cookieFormFieldset, preferences) { const cookieType = this.getCookieType($cookieFormFieldset) let preference = false @@ -54,8 +63,10 @@ class CookiesPage { } const radioValue = preference ? 'yes' : 'no' - const radio = $cookieFormFieldset.querySelector(`input[name=${cookieType}][value=${radioValue}]`) - radio.checked = true + + /** @satisfies {HTMLInputElement} */ + const $radio = $cookieFormFieldset.querySelector(`input[name=${cookieType}][value=${radioValue}]`) + $radio.checked = true } showSuccessNotification () { @@ -74,6 +85,10 @@ class CookiesPage { window.scrollTo(0, 0) } + /** + * @param {HTMLFieldSetElement} $cookieFormFieldset - Cookie form fieldset + * @returns {string} Cookie type + */ getCookieType ($cookieFormFieldset) { return $cookieFormFieldset.id } diff --git a/src/javascripts/components/copy.mjs b/src/javascripts/components/copy.mjs index 23c089e0c8..919bbec653 100644 --- a/src/javascripts/components/copy.mjs +++ b/src/javascripts/components/copy.mjs @@ -6,9 +6,7 @@ class Copy { } init () { - const $module = this.$module - - if (!$module) { + if (!this.$module) { return } @@ -17,7 +15,7 @@ class Copy { $button.setAttribute('aria-live', 'assertive') $button.textContent = 'Copy code' - $module.insertAdjacentElement('beforebegin', $button) + this.$module.insertAdjacentElement('beforebegin', $button) this.copyAction() } diff --git a/src/javascripts/components/example-page.mjs b/src/javascripts/components/example-page.mjs index c1e38f7c85..3b2a9f6170 100644 --- a/src/javascripts/components/example-page.mjs +++ b/src/javascripts/components/example-page.mjs @@ -4,16 +4,19 @@ class ExamplePage { } init () { - const $module = this.$module - - if (!$module) { + if (!this.$module) { return } - const $form = $module.querySelector('form[action="/form-handler"]') + /** @satisfies {HTMLFormElement | null} */ + const $form = this.$module.querySelector('form[action="/form-handler"]') this.preventFormSubmission($form) } + /** + * @param {HTMLFormElement | null} $form - Form + * @returns {false | void} Returns false for examples without forms + */ preventFormSubmission ($form) { // we should only have one form per example if (!$form) { diff --git a/src/javascripts/components/example.mjs b/src/javascripts/components/example.mjs index 0f6720c879..dc57009979 100644 --- a/src/javascripts/components/example.mjs +++ b/src/javascripts/components/example.mjs @@ -18,20 +18,18 @@ class Example { } init () { - const $module = this.$module - - if (!$module) { + if (!this.$module) { return } // Initialise asap for eager iframes or browsers which don't support lazy loading - if (!('loading' in $module) || $module.loading !== 'lazy') { - return iFrameResize({ scrolling: 'omit' }, $module) + if (!('loading' in this.$module) || this.$module.loading !== 'lazy') { + return iFrameResize({ scrolling: 'omit' }, this.$module) } - $module.addEventListener('load', () => { + this.$module.addEventListener('load', () => { try { - iFrameResize({ scrolling: 'omit' }, $module) + iFrameResize({ scrolling: 'omit' }, this.$module) } catch (err) { if (err) { console.error(err.message) diff --git a/src/javascripts/components/navigation.mjs b/src/javascripts/components/navigation.mjs index c69e93a2a6..25b5152723 100644 --- a/src/javascripts/components/navigation.mjs +++ b/src/javascripts/components/navigation.mjs @@ -73,31 +73,27 @@ class Navigation { } bindUIEvents () { - const $nav = this.$nav - const $navToggler = this.$navToggler - const $navButtons = this.$navButtons - - $navToggler.addEventListener('click', () => { + this.$navToggler.addEventListener('click', () => { if (this.mobileNavOpen) { - $nav.classList.remove(navActiveClass) - $navToggler.classList.remove(navMenuButtonActiveClass) - $nav.setAttribute('hidden', '') + this.$nav.classList.remove(navActiveClass) + this.$navToggler.classList.remove(navMenuButtonActiveClass) + this.$nav.setAttribute('hidden', '') - $navToggler.setAttribute('aria-expanded', 'false') + this.$navToggler.setAttribute('aria-expanded', 'false') this.mobileNavOpen = false } else { - $nav.classList.add(navActiveClass) - $navToggler.classList.add(navMenuButtonActiveClass) - $nav.removeAttribute('hidden') + this.$nav.classList.add(navActiveClass) + this.$navToggler.classList.add(navMenuButtonActiveClass) + this.$nav.removeAttribute('hidden') - $navToggler.setAttribute('aria-expanded', 'true') + this.$navToggler.setAttribute('aria-expanded', 'true') this.mobileNavOpen = true } }) - $navButtons.forEach(($button) => { + this.$navButtons.forEach(($button) => { $button.addEventListener('click', () => { const $nextSubNav = $button.parentNode.querySelector(subNavJSClass) diff --git a/src/javascripts/components/options-table.mjs b/src/javascripts/components/options-table.mjs index 78c67c8ac2..4237ebff9a 100644 --- a/src/javascripts/components/options-table.mjs +++ b/src/javascripts/components/options-table.mjs @@ -17,36 +17,36 @@ class OptionsTable { } if (exampleName) { - const tabLink = document.querySelector(`a[href="#${exampleName}-nunjucks"]`) - const tabHeading = tabLink ? tabLink.parentNode : null - const optionsDetailsElement = document.getElementById(`options-${exampleName}-details`) + const $tabLink = document.querySelector(`a[href="#${exampleName}-nunjucks"]`) + const $tabHeading = $tabLink ? $tabLink.parentNode : null + const $optionsDetailsElement = document.getElementById(`options-${exampleName}-details`) - if (tabHeading && optionsDetailsElement) { - const tabsElement = optionsDetailsElement.parentNode - const detailsSummary = optionsDetailsElement.querySelector('.govuk-details__summary') - const detailsText = optionsDetailsElement.querySelector('.govuk-details__text') + if ($tabHeading && $optionsDetailsElement) { + const $tabsElement = $optionsDetailsElement.parentNode + const $detailsSummary = $optionsDetailsElement.querySelector('.govuk-details__summary') + const $detailsText = $optionsDetailsElement.querySelector('.govuk-details__text') - if (detailsSummary && detailsText) { - tabLink.setAttribute('aria-expanded', 'true') - tabHeading.className += ' app-tabs__item--current' - tabsElement.removeAttribute('hidden') + if ($detailsSummary && $detailsText) { + $tabLink.setAttribute('aria-expanded', 'true') + $tabHeading.className += ' app-tabs__item--current' + $tabsElement.removeAttribute('hidden') - optionsDetailsElement.setAttribute('open', 'open') + $optionsDetailsElement.setAttribute('open', 'open') // If the browser does not natively support the

element // the polyfill included with the Details component adds ARIA // attributes and explicit display styles that we need to keep in // sync with the open attribute. - if (detailsSummary.hasAttribute('aria-expanded')) { - detailsSummary.setAttribute('aria-expanded', 'true') + if ($detailsSummary.hasAttribute('aria-expanded')) { + $detailsSummary.setAttribute('aria-expanded', 'true') } - if (detailsText.hasAttribute('aria-hidden')) { - detailsText.setAttribute('aria-hidden', false) + if ($detailsText.hasAttribute('aria-hidden')) { + $detailsText.setAttribute('aria-hidden', false) } - detailsText.style.display = '' + $detailsText.style.display = '' window.setTimeout(() => { - tabLink.focus() + $tabLink.focus() if (isLinkToTable) document.querySelector(hash).scrollIntoView() }, 0) } diff --git a/src/javascripts/components/search.mjs b/src/javascripts/components/search.mjs index 99f681ff75..4a3a952158 100644 --- a/src/javascripts/components/search.mjs +++ b/src/javascripts/components/search.mjs @@ -154,13 +154,12 @@ class Search { } init () { - const $module = this.$module - if (!$module) { + if (!this.$module) { return } accessibleAutocomplete({ - element: $module, + element: this.$module, id: 'app-site-search__input', cssNamespace: 'app-site-search', displayMenu: 'overlay', @@ -176,14 +175,14 @@ class Search { tNoResults: function () { return statusMessage } }) - const $input = $module.querySelector('.app-site-search__input') + const $input = this.$module.querySelector('.app-site-search__input') // Ensure if the user stops using the search that we do not send tracking events $input.addEventListener('blur', () => { clearTimeout(inputDebounceTimer) }) - const searchIndexUrl = $module.getAttribute('data-search-index') + const searchIndexUrl = this.$module.getAttribute('data-search-index') this.fetchSearchIndex(searchIndexUrl, () => { this.renderResults() }) diff --git a/src/javascripts/components/tabs.mjs b/src/javascripts/components/tabs.mjs index 60c6ae1f82..7aa498b18a 100644 --- a/src/javascripts/components/tabs.mjs +++ b/src/javascripts/components/tabs.mjs @@ -42,7 +42,9 @@ class AppTabs { } /** + * Handle tab clicks * + * @param {Event} event - Click event */ onClick (event) { event.preventDefault() @@ -105,6 +107,8 @@ class AppTabs { /** * Open a panel and set the associated controls and styles + * + * @param {string} panelId - Tab panel ID */ openPanel (panelId) { const $mobileTab = this.getMobileTab(panelId) @@ -124,6 +128,8 @@ class AppTabs { /** * Close a panel and set the associated controls and styles + * + * @param {string} panelId - Tab panel ID */ closePanel (panelId) { const $mobileTab = this.getMobileTab(panelId) @@ -137,6 +143,9 @@ class AppTabs { /** * Helper function to get a specific mobile tab by the associated panel ID + * + * @param {string} panelId - Tab panel ID + * @returns {HTMLButtonElement | null} Mobile tab button */ getMobileTab (panelId) { let result = null @@ -150,6 +159,9 @@ class AppTabs { /** * Helper function to get a specific desktop tab by the associated panel ID + * + * @param {string} panelId - Tab panel ID + * @returns {HTMLAnchorElement | null} Desktop tab link */ getDesktopTab (panelId) { const $desktopTabContainer = this.$module.querySelector('.app-tabs') @@ -161,6 +173,9 @@ class AppTabs { /** * Helper function to get a specific panel by ID + * + * @param {string} panelId - Tab panel ID + * @returns {HTMLElement | null} Tab panel */ getPanel (panelId) { return document.getElementById(panelId) From 91578774666c6f50e6957b531bfb4f59e2ed9663 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 20 Jul 2023 01:08:37 +0100 Subject: [PATCH 4/4] Remove checks for matchMedia All es6 modules supporting browsers support window.matchMedia https://caniuse.com/matchmedia --- src/javascripts/components/navigation.mjs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/javascripts/components/navigation.mjs b/src/javascripts/components/navigation.mjs index 25b5152723..679786397d 100644 --- a/src/javascripts/components/navigation.mjs +++ b/src/javascripts/components/navigation.mjs @@ -115,18 +115,16 @@ class Navigation { } init () { - if ('matchMedia' in window) { - // Set the matchMedia to the govuk-frontend tablet breakpoint - this.mql = window.matchMedia('(min-width: 40.0625em)') - - if ('addEventListener' in this.mql) { - this.mql.addEventListener('change', () => this.setHiddenStates()) - } else { - // addListener is a deprecated function, however addEventListener - // isn't supported by Safari < 14. We therefore add this in as - // a fallback for those browsers - this.mql.addListener(() => this.setHiddenStates()) - } + // Set the matchMedia to the govuk-frontend tablet breakpoint + this.mql = window.matchMedia('(min-width: 40.0625em)') + + // MediaQueryList.addEventListener isn't supported by Safari < 14 so we need + // to be able to fall back to the deprecated MediaQueryList.addListener + if ('addEventListener' in this.mql) { + this.mql.addEventListener('change', () => this.setHiddenStates()) + } else { + // @ts-expect-error Property 'addListener' does not exist + this.mql.addListener(() => this.setHiddenStates()) } this.setHiddenStates()