diff --git a/lib/metalsmith.js b/lib/metalsmith.js index dc16f9ddc2..a94f40be8a 100644 --- a/lib/metalsmith.js +++ b/lib/metalsmith.js @@ -115,7 +115,8 @@ module.exports = metalsmith // ignore files from build .ignore([ '.DS_Store', - '.eslintrc.js' + '.eslintrc.js', + 'tsconfig.json' ]) // convert *.scss files to *.css diff --git a/src/javascripts/application-example.mjs b/src/javascripts/application-example.mjs index abcb0bdd98..2e8fd3868f 100644 --- a/src/javascripts/application-example.mjs +++ b/src/javascripts/application-example.mjs @@ -10,4 +10,5 @@ initAll({ notificationBanner: { disableAutoFocus: true } }) -new ExamplePage(document).init() +// eslint-disable-next-line no-new +new ExamplePage(document) diff --git a/src/javascripts/application.mjs b/src/javascripts/application.mjs index 30123f33c3..432a81b6ea 100644 --- a/src/javascripts/application.mjs +++ b/src/javascripts/application.mjs @@ -1,3 +1,5 @@ +/* eslint-disable no-new */ + import { initAll } from 'govuk-frontend' import Analytics from './components/analytics.mjs' @@ -17,7 +19,9 @@ initAll() // Initialise cookie banner const $cookieBanner = document.querySelector('[data-module="govuk-cookie-banner"]') -new CookieBanner($cookieBanner).init() +if ($cookieBanner) { + new CookieBanner($cookieBanner) +} // Initialise analytics if consent is given const userConsent = getConsentCookie() @@ -28,35 +32,41 @@ if (userConsent && isValidConsentCookie(userConsent) && userConsent.analytics) { // Initialise example frames const $examples = document.querySelectorAll('[data-module="app-example-frame"]') $examples.forEach(($example) => { - new Example($example).init() + new Example($example) }) // Initialise tabs const $tabs = document.querySelectorAll('[data-module="app-tabs"]') $tabs.forEach(($tabs) => { - new AppTabs($tabs).init() + new AppTabs($tabs) }) // Do this after initialising tabs -new OptionsTable().init() +new OptionsTable() // Add copy to clipboard to code blocks inside tab containers const $codeBlocks = document.querySelectorAll('[data-module="app-copy"] pre') $codeBlocks.forEach(($codeBlock) => { - new Copy($codeBlock).init() + new Copy($codeBlock) }) // Initialise mobile navigation -new Navigation().init() +new Navigation(document) // Initialise search const $searchContainer = document.querySelector('[data-module="app-search"]') -new Search($searchContainer).init() +if ($searchContainer) { + new Search($searchContainer) +} // Initialise back to top const $backToTop = document.querySelector('[data-module="app-back-to-top"]') -new BackToTop($backToTop).init() +if ($backToTop) { + new BackToTop($backToTop) +} // Initialise cookie page const $cookiesPage = document.querySelector('[data-module="app-cookies-page"]') -new CookiesPage($cookiesPage).init() +if ($cookiesPage) { + new CookiesPage($cookiesPage) +} diff --git a/src/javascripts/components/analytics.mjs b/src/javascripts/components/analytics.mjs index a4bc9a184b..2c8f668557 100644 --- a/src/javascripts/components/analytics.mjs +++ b/src/javascripts/components/analytics.mjs @@ -1,3 +1,5 @@ +// @ts-nocheck + export default function loadAnalytics () { if (!window.ga || !window.ga.loaded) { // Load gtm script diff --git a/src/javascripts/components/back-to-top.mjs b/src/javascripts/components/back-to-top.mjs index 9937019de1..228be7f88f 100644 --- a/src/javascripts/components/back-to-top.mjs +++ b/src/javascripts/components/back-to-top.mjs @@ -1,18 +1,20 @@ class BackToTop { + /** + * @param {Element} $module - HTML element + */ constructor ($module) { - this.$module = $module - } - - init () { - if (!this.$module) { - return + if (!($module instanceof HTMLElement)) { + return this } + 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') + this.$module.classList.remove('app-back-to-top--hidden') + return this } const $footer = document.querySelector('.app-footer') @@ -20,7 +22,7 @@ class BackToTop { // Check if there is anything to observe if (!$footer || !$subNav) { - return + return this } let footerIsIntersecting = false diff --git a/src/javascripts/components/cookie-banner.mjs b/src/javascripts/components/cookie-banner.mjs index 36d0e71eeb..a58eab24da 100644 --- a/src/javascripts/components/cookie-banner.mjs +++ b/src/javascripts/components/cookie-banner.mjs @@ -8,11 +8,16 @@ const cookieConfirmationAcceptSelector = '.js-cookie-banner-confirmation-accept' const cookieConfirmationRejectSelector = '.js-cookie-banner-confirmation-reject' class CookieBanner { + /** + * @param {Element} $module - HTML element + */ constructor ($module) { + if (!($module instanceof HTMLElement)) { + return this + } + this.$module = $module - } - init () { this.$cookieBanner = this.$module this.$acceptButton = this.$module.querySelector(cookieBannerAcceptSelector) this.$rejectButton = this.$module.querySelector(cookieBannerRejectSelector) @@ -48,7 +53,7 @@ class CookieBanner { } hideBanner () { - this.$cookieBanner.setAttribute('hidden', true) + this.$cookieBanner.setAttribute('hidden', 'true') } acceptCookies () { @@ -56,7 +61,7 @@ class CookieBanner { CookieFunctions.setConsentCookie({ analytics: true }) // Hide banner and show confirmation message - this.$cookieMessage.setAttribute('hidden', true) + this.$cookieMessage.setAttribute('hidden', 'true') this.revealConfirmationMessage(this.$cookieConfirmationAccept) } @@ -65,7 +70,7 @@ class CookieBanner { CookieFunctions.setConsentCookie({ analytics: false }) // Hide banner and show confirmation message - this.$cookieMessage.setAttribute('hidden', true) + this.$cookieMessage.setAttribute('hidden', 'true') this.revealConfirmationMessage(this.$cookieConfirmationReject) } diff --git a/src/javascripts/components/cookie-functions.mjs b/src/javascripts/components/cookie-functions.mjs index 89a484713f..83d3078e20 100644 --- a/src/javascripts/components/cookie-functions.mjs +++ b/src/javascripts/components/cookie-functions.mjs @@ -114,6 +114,7 @@ export function getConsentCookie () { * @returns {boolean} True if consent cookie is valid */ export function isValidConsentCookie (options) { + // @ts-expect-error Property does not exist on window return (options && options.version >= window.GDS_CONSENT_COOKIE_VERSION) } @@ -137,6 +138,7 @@ export function setConsentCookie (options) { // Essential cookies cannot be deselected, ignore this cookie type delete cookieConsent.essential + // @ts-expect-error Property does not exist on window cookieConsent.version = window.GDS_CONSENT_COOKIE_VERSION // Set the consent cookie @@ -276,7 +278,7 @@ function setCookie (name, value, options) { if (options.days) { const date = new Date() date.setTime(date.getTime() + (options.days * 24 * 60 * 60 * 1000)) - cookieString = `${cookieString}; expires=${date.toGMTString()}` + cookieString = `${cookieString}; expires=${date.toUTCString()}` } if (document.location.protocol === 'https:') { cookieString = `${cookieString}; Secure` diff --git a/src/javascripts/components/cookies-page.mjs b/src/javascripts/components/cookies-page.mjs index 14b8c72816..b57bd53efc 100644 --- a/src/javascripts/components/cookies-page.mjs +++ b/src/javascripts/components/cookies-page.mjs @@ -1,20 +1,32 @@ import { getConsentCookie, setConsentCookie } from './cookie-functions.mjs' class CookiesPage { + /** + * @param {Element} $module - HTML element + */ constructor ($module) { - this.$module = $module - } - - init () { - this.$cookiePage = this.$module + if (!($module instanceof HTMLElement)) { + return this + } - if (!this.$cookiePage) { - return + const $cookieForm = $module.querySelector('.js-cookies-page-form') + if (!($cookieForm instanceof HTMLFormElement)) { + return this } - 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') + /** @satisfies {NodeListOf} */ + const $cookieFormFieldsets = $cookieForm.querySelectorAll('.js-cookies-page-form-fieldset') + const $cookieFormButton = $cookieForm.querySelector('.js-cookies-form-button') + + this.$page = $module + this.$cookieForm = $cookieForm + this.$cookieFormFieldsets = $cookieFormFieldsets + this.$cookieFormButton = $cookieFormButton + + const $successNotification = $module.querySelector('.js-cookies-page-success') + if ($successNotification instanceof HTMLElement) { + this.$successNotification = $successNotification + } this.$cookieFormFieldsets.forEach(($cookieFormFieldset) => { this.showUserPreference($cookieFormFieldset, getConsentCookie()) @@ -22,7 +34,7 @@ class CookiesPage { }) // Show submit button - this.$cookieForm.querySelector('.js-cookies-form-button').removeAttribute('hidden') + this.$cookieFormButton.removeAttribute('hidden') this.$cookieForm.addEventListener('submit', (event) => this.savePreferences(event)) } @@ -70,6 +82,10 @@ class CookiesPage { } showSuccessNotification () { + if (!this.$successNotification) { + return + } + this.$successNotification.removeAttribute('hidden') // Set tabindex to -1 to make the element focusable with JavaScript. diff --git a/src/javascripts/components/copy.mjs b/src/javascripts/components/copy.mjs index 919bbec653..8d72bc6904 100644 --- a/src/javascripts/components/copy.mjs +++ b/src/javascripts/components/copy.mjs @@ -1,15 +1,16 @@ import ClipboardJS from 'clipboard' class Copy { + /** + * @param {Element} $module - HTML element + */ constructor ($module) { - this.$module = $module - } - - init () { - if (!this.$module) { - return + if (!($module instanceof HTMLElement)) { + return this } + this.$module = $module + const $button = document.createElement('button') $button.className = 'app-copy-button js-copy-button' $button.setAttribute('aria-live', 'assertive') diff --git a/src/javascripts/components/example-page.mjs b/src/javascripts/components/example-page.mjs index 3b2a9f6170..16172e6fdf 100644 --- a/src/javascripts/components/example-page.mjs +++ b/src/javascripts/components/example-page.mjs @@ -1,13 +1,14 @@ class ExamplePage { + /** + * @param {Document} $module - HTML document + */ constructor ($module) { - this.$module = $module - } - - init () { - if (!this.$module) { - return + if (!($module instanceof Document)) { + return this } + this.$module = $module + /** @satisfies {HTMLFormElement | null} */ const $form = this.$module.querySelector('form[action="/form-handler"]') this.preventFormSubmission($form) diff --git a/src/javascripts/components/example.mjs b/src/javascripts/components/example.mjs index dc57009979..0930f0050e 100644 --- a/src/javascripts/components/example.mjs +++ b/src/javascripts/components/example.mjs @@ -9,18 +9,15 @@ import iFrameResize from 'iframe-resizer/js/iframeResizer.js' * @param {Element} $module - HTML element to use for example */ class Example { + /** + * @param {Element} $module - HTML element + */ constructor ($module) { if (!($module instanceof HTMLIFrameElement)) { return } this.$module = $module - } - - init () { - if (!this.$module) { - return - } // Initialise asap for eager iframes or browsers which don't support lazy loading if (!('loading' in this.$module) || this.$module.loading !== 'lazy') { diff --git a/src/javascripts/components/navigation.mjs b/src/javascripts/components/navigation.mjs index 679786397d..6f4310e9f8 100644 --- a/src/javascripts/components/navigation.mjs +++ b/src/javascripts/components/navigation.mjs @@ -5,8 +5,15 @@ const subNavActiveClass = 'app-navigation__subnav--active' const subNavJSClass = '.js-app-navigation__subnav' class Navigation { + /** + * @param {Document} $module - HTML document + */ constructor ($module) { - this.$module = $module || document + if (!($module instanceof Document)) { + return this + } + + this.$module = $module this.$nav = this.$module.querySelector('.js-app-navigation') this.$navToggler = this.$module.querySelector('.js-app-navigation__toggler') @@ -17,15 +24,28 @@ class Navigation { 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 + // 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() + this.setInitialAriaStates() + this.bindUIEvents() } // 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.mql.matches) { if (!this.mobileNavOpen) { this.$nav.setAttribute('hidden', '') } @@ -39,7 +59,7 @@ class Navigation { }) this.$navToggler.removeAttribute('hidden') - } else if (this.mql === null || this.mql.matches) { + } else { this.$nav.removeAttribute('hidden') this.$navLinks.forEach(($navLink) => { @@ -58,7 +78,7 @@ class Navigation { this.$navToggler.setAttribute('aria-expanded', 'false') this.$navButtons.forEach(($button, index) => { - const $nextSubNav = $button.parentNode.querySelector(subNavJSClass) + const $nextSubNav = $button.parentElement.querySelector(subNavJSClass) if ($nextSubNav) { const subNavTogglerId = `js-mobile-nav-subnav-toggler-${index}` @@ -95,7 +115,7 @@ class Navigation { this.$navButtons.forEach(($button) => { $button.addEventListener('click', () => { - const $nextSubNav = $button.parentNode.querySelector(subNavJSClass) + const $nextSubNav = $button.parentElement.querySelector(subNavJSClass) if ($nextSubNav) { if ($nextSubNav.hasAttribute('hidden')) { @@ -113,24 +133,6 @@ class Navigation { }) }) } - - init () { - // 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() - this.setInitialAriaStates() - this.bindUIEvents() - } } export default Navigation diff --git a/src/javascripts/components/options-table.mjs b/src/javascripts/components/options-table.mjs index 4237ebff9a..dda327e4a4 100644 --- a/src/javascripts/components/options-table.mjs +++ b/src/javascripts/components/options-table.mjs @@ -1,5 +1,5 @@ class OptionsTable { - init () { + constructor () { this.expandMacroOptions() } @@ -18,15 +18,19 @@ class OptionsTable { if (exampleName) { const $tabLink = document.querySelector(`a[href="#${exampleName}-nunjucks"]`) - const $tabHeading = $tabLink ? $tabLink.parentNode : null + if (!($tabLink instanceof HTMLAnchorElement)) { + return + } + + const $tabHeading = $tabLink.parentElement const $optionsDetailsElement = document.getElementById(`options-${exampleName}-details`) if ($tabHeading && $optionsDetailsElement) { - const $tabsElement = $optionsDetailsElement.parentNode + const $tabsElement = $optionsDetailsElement.parentElement const $detailsSummary = $optionsDetailsElement.querySelector('.govuk-details__summary') const $detailsText = $optionsDetailsElement.querySelector('.govuk-details__text') - if ($detailsSummary && $detailsText) { + if ($detailsSummary && $detailsText instanceof HTMLElement) { $tabLink.setAttribute('aria-expanded', 'true') $tabHeading.className += ' app-tabs__item--current' $tabsElement.removeAttribute('hidden') @@ -41,7 +45,7 @@ class OptionsTable { $detailsSummary.setAttribute('aria-expanded', 'true') } if ($detailsText.hasAttribute('aria-hidden')) { - $detailsText.setAttribute('aria-hidden', false) + $detailsText.setAttribute('aria-hidden', 'false') } $detailsText.style.display = '' diff --git a/src/javascripts/components/search.mjs b/src/javascripts/components/search.mjs index 4a3a952158..14cb54bcb7 100644 --- a/src/javascripts/components/search.mjs +++ b/src/javascripts/components/search.mjs @@ -14,7 +14,7 @@ let documentStore = null let statusMessage = null let searchQuery = '' -let searchCallback = function () {} +let searchCallback = function (searchResults) {} // Results that are rendered by the autocomplete let searchResults = [] @@ -26,13 +26,50 @@ let inputDebounceTimer = null const DEBOUNCE_TIME_TO_WAIT = function () { // We want to be able to reduce this timeout in order to make sure // our tests do not run very slowly. + // @ts-expect-error Property does not exist on window const timeout = window.__SITE_SEARCH_TRACKING_TIMEOUT return (typeof timeout !== 'undefined') ? timeout : 2000 // milliseconds } class Search { + /** + * @param {Element} $module - HTML element + */ constructor ($module) { + if (!($module instanceof HTMLElement)) { + return this + } + this.$module = $module + + accessibleAutocomplete({ + element: this.$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 = 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 = this.$module.getAttribute('data-search-index') + this.fetchSearchIndex(searchIndexUrl, () => { + this.renderResults() + }) } fetchSearchIndex (indexUrl, callback) { @@ -152,41 +189,6 @@ class Search { return elem.innerHTML } } - - init () { - if (!this.$module) { - return - } - - accessibleAutocomplete({ - element: this.$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 = 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 = this.$module.getAttribute('data-search-index') - this.fetchSearchIndex(searchIndexUrl, () => { - this.renderResults() - }) - } } export default Search diff --git a/src/javascripts/components/search.tracking.mjs b/src/javascripts/components/search.tracking.mjs index 1f75b90a76..f5670b77e1 100644 --- a/src/javascripts/components/search.tracking.mjs +++ b/src/javascripts/components/search.tracking.mjs @@ -1,5 +1,7 @@ function addToDataLayer (payload) { + // @ts-expect-error Property does not exist on window window.dataLayer = window.dataLayer || [] + // @ts-expect-error Property does not exist on window window.dataLayer.push(payload) } @@ -15,7 +17,7 @@ function stripPossiblePII (string) { } export function trackConfirm (searchQuery, searchResults, result) { - if (window.DO_NOT_TRACK_ENABLED) { + if ('DO_NOT_TRACK_ENABLED' in window && window.DO_NOT_TRACK_ENABLED) { return } @@ -48,7 +50,7 @@ export function trackConfirm (searchQuery, searchResults, result) { } export function trackSearchResults (searchQuery, searchResults) { - if (window.DO_NOT_TRACK_ENABLED) { + if ('DO_NOT_TRACK_ENABLED' in window && window.DO_NOT_TRACK_ENABLED) { return } diff --git a/src/javascripts/components/tabs.mjs b/src/javascripts/components/tabs.mjs index 7aa498b18a..2407226c6e 100644 --- a/src/javascripts/components/tabs.mjs +++ b/src/javascripts/components/tabs.mjs @@ -10,18 +10,18 @@ */ class AppTabs { + /** + * @param {Element} $module - HTML element + */ constructor ($module) { + if (!($module instanceof HTMLElement)) { + return this + } + 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') - } - - init () { - // Exit if no module has been defined - if (!this.$module) { - return - } // Enhance mobile tabs into buttons this.enhanceMobileTabs() @@ -48,7 +48,12 @@ class AppTabs { */ onClick (event) { event.preventDefault() + const $currentTab = event.target + if (!($currentTab instanceof HTMLElement)) { + return + } + const panelId = $currentTab.getAttribute('aria-controls') const $panel = this.getPanel(panelId) const isTabAlreadyOpen = $currentTab.getAttribute('aria-expanded') === 'true' @@ -118,9 +123,9 @@ class AppTabs { // 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') + $mobileTab.parentElement.classList.add('app-tabs__heading--current') $desktopTab.setAttribute('aria-expanded', 'true') - $desktopTab.parentNode.classList.add('app-tabs__item--current') + $desktopTab.parentElement.classList.add('app-tabs__item--current') } this.getPanel(panelId).removeAttribute('hidden') @@ -136,8 +141,8 @@ class AppTabs { 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') + $mobileTab.parentElement.classList.remove('app-tabs__heading--current') + $desktopTab.parentElement.classList.remove('app-tabs__item--current') this.getPanel(panelId).setAttribute('hidden', 'hidden') } diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000000..899a61af13 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["**/*.js", "**/*.mjs"], + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ES2015" + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000000..db0342ff42 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "module": "ESNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": false, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "target": "ESNext" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..1921e8819a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { + "path": "./src" + } + ] +}