diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000000..ec077162db --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,9 @@ +/** + * As govuk-frontend provides no types, TypeScript will type its exports as `any`, + * but be unable to acknowledge fields inherited from parent classes + * leading to errors when trying to assign or use them. + * + * TypeScript's shorthand ambient modules seem to also make inherited fields typed as `any`. + * https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html#shorthand-ambient-module-declarations + */ +declare module "govuk-frontend"; \ No newline at end of file diff --git a/src/javascripts/application.mjs b/src/javascripts/application.mjs index 9a0b48f886..0039759f75 100644 --- a/src/javascripts/application.mjs +++ b/src/javascripts/application.mjs @@ -48,7 +48,7 @@ createAll(Copy) new OptionsTable() // Initialise mobile navigation -new Navigation(document) +createAll(Navigation) // Initialise scrollable container handling createAll(ScrollContainer) @@ -70,7 +70,11 @@ const lazyEmbedObserver = new IntersectionObserver(function ( ) { entries.forEach(function (entry) { if (entry.isIntersecting) { - new EmbedCard(entry.target) + try { + new EmbedCard(entry.target) + } catch (error) { + console.log(error) + } } }) }) diff --git a/src/javascripts/components/back-to-top.mjs b/src/javascripts/components/back-to-top.mjs index ac2c3eb89f..a49d3a69e0 100644 --- a/src/javascripts/components/back-to-top.mjs +++ b/src/javascripts/components/back-to-top.mjs @@ -1,34 +1,28 @@ +import { Component } from 'govuk-frontend' + /** * Website back to top link */ -class BackToTop { +class BackToTop extends Component { static moduleName = 'app-back-to-top' /** * @param {Element} $module - HTML element */ constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') - ) { - return this - } + super($module) - this.$module = $module + 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 - this.$module.classList.remove('app-back-to-top--hidden') + this.$root.classList.remove('app-back-to-top--hidden') return this } - const $footer = document.querySelector('.app-footer') - const $subNav = document.querySelector('.app-subnav') - - // Check if there is anything to observe if (!$footer || !$subNav) { return this } @@ -53,17 +47,17 @@ class BackToTop { // 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') + this.$root.classList.remove('app-back-to-top--fixed') } else { - this.$module.classList.add('app-back-to-top--fixed') + this.$root.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') + this.$root.classList.add('app-back-to-top--hidden') } else { - this.$module.classList.remove('app-back-to-top--hidden') + this.$root.classList.remove('app-back-to-top--hidden') } }) diff --git a/src/javascripts/components/cookie-banner.mjs b/src/javascripts/components/cookie-banner.mjs index 60a8bf882b..0d8887a9ef 100644 --- a/src/javascripts/components/cookie-banner.mjs +++ b/src/javascripts/components/cookie-banner.mjs @@ -1,3 +1,5 @@ +import { Component } from 'govuk-frontend' + import * as CookieFunctions from './cookie-functions.mjs' const cookieBannerAcceptSelector = '.js-cookie-banner-accept' @@ -10,26 +12,36 @@ const cookieConfirmationRejectSelector = '.js-cookie-banner-confirmation-reject' /** * Website cookie banner */ -class CookieBanner { +class CookieBanner extends Component { + /** + * Check support of CookieBanner + */ + static checkSupport() { + Component.checkSupport() + + if (CookieBanner.onCookiesPage()) { + throw Error('Cancelled initialisation as on cookie page') + } + } + + /** + * Check if on the Cookies page + * + * @returns {boolean} Returns true if on the Cookies page + */ + static onCookiesPage() { + return window.location.pathname === '/cookies/' + } + static moduleName = 'govuk-cookie-banner' /** * @param {Element} $module - HTML element */ constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') || - // Exit if we're on the cookies page to avoid circular journeys - this.onCookiesPage() - ) { - return this - } + super($module) - this.$cookieBanner = $module this.cookieCategory = - (this.$cookieBanner.dataset && - this.$cookieBanner.dataset.cookieCategory) || - 'analytics' + (this.$root.dataset && this.$root.dataset.cookieCategory) || 'analytics' const $acceptButton = $module.querySelector(cookieBannerAcceptSelector) const $rejectButton = $module.querySelector(cookieBannerRejectSelector) @@ -60,7 +72,7 @@ class CookieBanner { this.$cookieMessage = $cookieMessage this.$cookieConfirmationAccept = $cookieConfirmationAccept this.$cookieConfirmationReject = $cookieConfirmationReject - this.$cookieBannerHideButtons = $cookieBannerHideButtons + this.$rootHideButtons = $cookieBannerHideButtons // Show the cookie banner to users who have not consented or have an // outdated consent cookie @@ -74,13 +86,13 @@ class CookieBanner { // set previously CookieFunctions.resetCookies() - this.$cookieBanner.removeAttribute('hidden') + this.$root.removeAttribute('hidden') } this.$acceptButton.addEventListener('click', () => this.acceptCookies()) this.$rejectButton.addEventListener('click', () => this.rejectCookies()) - this.$cookieBannerHideButtons.forEach(($cookieBannerHideButton) => { + this.$rootHideButtons.forEach(($cookieBannerHideButton) => { $cookieBannerHideButton.addEventListener('click', () => this.hideBanner()) }) } @@ -89,7 +101,7 @@ class CookieBanner { * Hide banner */ hideBanner() { - this.$cookieBanner.setAttribute('hidden', 'true') + this.$root.setAttribute('hidden', 'true') } /** @@ -135,15 +147,6 @@ class CookieBanner { confirmationMessage.focus() } - - /** - * Check if on the Cookies page - * - * @returns {boolean} Returns true if on the Cookies page - */ - 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 58f394e6b6..dc93813247 100644 --- a/src/javascripts/components/cookies-page.mjs +++ b/src/javascripts/components/cookies-page.mjs @@ -1,24 +1,19 @@ +import { Component } from 'govuk-frontend' + import { getConsentCookie, setConsentCookie } from './cookie-functions.mjs' /** * Website cookies page */ -class CookiesPage { +class CookiesPage extends Component { static moduleName = 'app-cookies-page' /** * @param {Element} $module - HTML element */ constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') - ) { - return this - } - - this.$page = $module + super($module) - const $cookieForm = this.$page.querySelector('.js-cookies-page-form') + const $cookieForm = this.$root.querySelector('.js-cookies-page-form') if (!($cookieForm instanceof HTMLFormElement)) { return this } @@ -43,7 +38,7 @@ class CookiesPage { this.$cookieFormFieldsets = $cookieFormFieldsets this.$cookieFormButton = $cookieFormButton - const $successNotification = this.$page.querySelector( + const $successNotification = this.$root.querySelector( '.js-cookies-page-success' ) if ($successNotification instanceof HTMLElement) { diff --git a/src/javascripts/components/copy.mjs b/src/javascripts/components/copy.mjs index 756eb15324..66d2dbaf79 100644 --- a/src/javascripts/components/copy.mjs +++ b/src/javascripts/components/copy.mjs @@ -1,26 +1,30 @@ import ClipboardJS from 'clipboard' +import { Component } from 'govuk-frontend' /** * Copy button for code examples */ -class Copy { +class Copy extends Component { static moduleName = 'app-copy' /** - * @param {Element} $module - HTML element + * Check if ClipboardJS is supported */ - constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') || - !ClipboardJS.isSupported() - ) { - return this + static checkSupport() { + Component.checkSupport() + + if (!ClipboardJS.isSupported()) { + throw Error('ClipboardJS not supported in this browser') } + } - this.$module = $module + /** + * @param {Element} $module - HTML element + */ + constructor($module) { + super($module) - this.$pre = this.$module.querySelector('pre') + this.$pre = this.$root.querySelector('pre') // TODO: Throw once GOV.UK Frontend exports its errors /** @type {number | null} */ @@ -34,8 +38,8 @@ class Copy { this.$status.className = 'govuk-visually-hidden' this.$status.setAttribute('aria-live', 'assertive') - this.$module.prepend(this.$status) - this.$module.prepend(this.$button) + this.$root.prepend(this.$status) + this.$root.prepend(this.$button) const $clipboard = new ClipboardJS(this.$button, { target: () => this.$pre diff --git a/src/javascripts/components/embed-card.mjs b/src/javascripts/components/embed-card.mjs index 2593ee78cc..6394c99490 100644 --- a/src/javascripts/components/embed-card.mjs +++ b/src/javascripts/components/embed-card.mjs @@ -1,21 +1,31 @@ +import { Component } from 'govuk-frontend' + import { getConsentCookie } from './cookie-functions.mjs' /** * Embed Card Youtube functionality */ -class EmbedCard { +class EmbedCard extends Component { /** - * @param {Element} $module - HTML element + * Check EmbedCard support */ - constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') - ) { - return this + static checkSupport() { + Component.checkSupport() + + const consentCookie = getConsentCookie() + + if (!consentCookie || (consentCookie && !consentCookie.campaign)) { + throw Error('Campaign consent cookies not accepted') } + } + + static moduleName = 'app-embed-card' - this.$module = $module + /** + * @param {Element} $module - HTML element + */ + constructor($module) { + super($module) this.replacePlaceholder() } @@ -26,17 +36,17 @@ class EmbedCard { * Replaces the placeholder with the iframe if cookies are set. */ replacePlaceholder() { - if (this.$module.querySelector('iframe')) { + if (this.$root.querySelector('iframe')) { return } const consentCookie = getConsentCookie() if (consentCookie && consentCookie.campaign) { - const placeholder = this.$module.querySelector( + const placeholder = this.$root.querySelector( '.app-embed-card__placeholder' ) - const placeholderText = this.$module.querySelector( + const placeholderText = this.$root.querySelector( '.app-embed-card__placeholder-text' ) @@ -51,7 +61,7 @@ class EmbedCard { placeholder.remove() - const iframeContainer = this.$module.querySelector( + const iframeContainer = this.$root.querySelector( '.app-embed-card__placeholder-iframe-container' ) iframeContainer.appendChild(iframe) @@ -65,6 +75,7 @@ class EmbedCard { * * @param {string} ytId - YouTube ID * @param {string} title - Title for iFrame (for screen readers) + * @returns {HTMLElement} - iframe element */ createIframe(ytId, title) { const iframe = document.createElement('IFRAME') diff --git a/src/javascripts/components/example-frame.mjs b/src/javascripts/components/example-frame.mjs index b542c53416..d84e424ed8 100644 --- a/src/javascripts/components/example-frame.mjs +++ b/src/javascripts/components/example-frame.mjs @@ -1,3 +1,4 @@ +import { Component } from 'govuk-frontend' import iFrameResize from 'iframe-resizer/js/iframeResizer.js' /** @@ -7,30 +8,24 @@ import iFrameResize from 'iframe-resizer/js/iframeResizer.js' * template wrappers. * * @param {Element} $module - HTML element to use for example + * @augments Component */ -class ExampleFrame { +class ExampleFrame extends Component { static moduleName = 'app-example-frame' /** * @param {Element} $module - HTML element */ constructor($module) { - if ( - !($module instanceof HTMLIFrameElement) || - !document.body.classList.contains('govuk-frontend-supported') - ) { - return - } - - this.$module = $module + super($module) // Initialise asap for eager iframes or browsers which don't support lazy loading - if (!('loading' in this.$module) || this.$module.loading !== 'lazy') { - return iFrameResize({ scrolling: 'omit' }, this.$module) + if (!('loading' in this.$root) || this.$root.loading !== 'lazy') { + return iFrameResize({ scrolling: 'omit' }, this.$root) } - this.$module.addEventListener('load', () => { + this.$root.addEventListener('load', () => { try { - iFrameResize({ scrolling: 'omit' }, this.$module) + iFrameResize({ scrolling: 'omit' }, this.$root) } catch (error) { if (error instanceof Error) { console.error(error.message) diff --git a/src/javascripts/components/navigation.mjs b/src/javascripts/components/navigation.mjs index 8309495e2d..f941cbc407 100644 --- a/src/javascripts/components/navigation.mjs +++ b/src/javascripts/components/navigation.mjs @@ -1,3 +1,5 @@ +import { Component } from 'govuk-frontend' + const navActiveClass = 'app-navigation--active' const navMenuButtonActiveClass = 'govuk-header__menu-button--open' const subNavActiveClass = 'app-navigation__subnav--active' @@ -7,28 +9,24 @@ const subNavJSClass = '.js-app-navigation__subnav' /** * Website navigation */ -class Navigation { +class Navigation extends Component { /** - * @param {Document} $module - HTML document + * Name for the component used when initialising using data-module attributes. */ - constructor($module) { - if ( - !($module instanceof Document) || - !document.body.classList.contains('govuk-frontend-supported') - ) { - return this - } + static moduleName = 'app-navigation' - this.$module = $module + /** + * @param {HTMLElement} $module - HTML document + */ + constructor($module) { + super($module) - const $nav = this.$module.querySelector('.js-app-navigation') - const $navToggler = this.$module.querySelector( - '.js-app-navigation__toggler' - ) - const $navButtons = this.$module.querySelectorAll( + const $nav = this.$root.querySelector('.js-app-navigation') + const $navToggler = this.$root.querySelector('.js-app-navigation__toggler') + const $navButtons = this.$root.querySelectorAll( '.js-app-navigation__button' ) - const $navLinks = this.$module.querySelectorAll('.js-app-navigation__link') + const $navLinks = this.$root.querySelectorAll('.js-app-navigation__link') if ( !($nav instanceof HTMLElement) || diff --git a/src/javascripts/components/scroll-container.mjs b/src/javascripts/components/scroll-container.mjs index 5616b0d02b..bf9354e33b 100644 --- a/src/javascripts/components/scroll-container.mjs +++ b/src/javascripts/components/scroll-container.mjs @@ -1,3 +1,5 @@ +import { Component } from 'govuk-frontend' + const scrollContainerResizeObserver = new window.ResizeObserver((entries) => { for (const entry of entries) { if (ScrollContainer.isOverflowing(entry.target)) { @@ -11,22 +13,26 @@ const scrollContainerResizeObserver = new window.ResizeObserver((entries) => { /** * */ -class ScrollContainer { +class ScrollContainer extends Component { static moduleName = 'app-scroll-container' /** - * @param {Element} $module - HTML element + * Checks if ResizeObserver supported */ - constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') || - !('ResizeObserver' in window) - ) { - return this + static isSupported() { + Component.checkSupport() + + if (!('ResizeObserver' in window)) { + throw Error('Browser does not support ResizeObserver') } + } - scrollContainerResizeObserver.observe($module) + /** + * @param {Element} $module - HTML element + */ + constructor($module) { + super($module) + scrollContainerResizeObserver.observe(this.$root) } /** diff --git a/src/javascripts/components/search.mjs b/src/javascripts/components/search.mjs index 6dd3bdb07e..c82780b212 100644 --- a/src/javascripts/components/search.mjs +++ b/src/javascripts/components/search.mjs @@ -1,5 +1,6 @@ /* global XMLHttpRequest */ import accessibleAutocomplete from 'accessible-autocomplete' +import { Component } from 'govuk-frontend' import lunr from 'lunr' import { trackSearchResults, trackConfirm } from './search.tracking.mjs' @@ -36,23 +37,16 @@ const DEBOUNCE_TIME_TO_WAIT = () => { /** * Website search */ -class Search { +class Search extends Component { static moduleName = 'app-search' /** * @param {Element} $module - HTML element */ constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') - ) { - return this - } - - this.$module = $module + super($module) accessibleAutocomplete({ - element: this.$module, + element: this.$root, id: 'app-site-search__input', cssNamespace: 'app-site-search', displayMenu: 'overlay', @@ -68,7 +62,7 @@ class Search { tNoResults: () => statusMessage }) - const $input = this.$module.querySelector('.app-site-search__input') + const $input = this.$root.querySelector('.app-site-search__input') if (!$input) { return this } @@ -78,7 +72,7 @@ class Search { clearTimeout(inputDebounceTimer) }) - const searchIndexUrl = this.$module.getAttribute('data-search-index') + const searchIndexUrl = this.$root.getAttribute('data-search-index') this.fetchSearchIndex(searchIndexUrl, () => { this.renderResults() }) diff --git a/src/javascripts/components/tabs.mjs b/src/javascripts/components/tabs.mjs index 2ba79ee417..e5ef2c5d7e 100644 --- a/src/javascripts/components/tabs.mjs +++ b/src/javascripts/components/tabs.mjs @@ -1,3 +1,5 @@ +import { Component } from 'govuk-frontend' + /** * The naming of things is a little complicated in here. * For reference: @@ -8,24 +10,18 @@ * - desktop tabs - the controls to show, hide or switch panels on tablet/desktop * - panels - the content that is shown/hidden/switched; same across all breakpoints */ -class AppTabs { +class AppTabs extends Component { static moduleName = 'app-tabs' /** * @param {Element} $module - HTML element */ constructor($module) { - if ( - !($module instanceof HTMLElement) || - !document.body.classList.contains('govuk-frontend-supported') - ) { - return this - } + super($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') + this.$mobileTabs = this.$root.querySelectorAll('.js-tabs__heading a') + this.$desktopTabs = this.$root.querySelectorAll('.js-tabs__item a') + this.$panels = this.$root.querySelectorAll('.js-tabs__container') // Enhance mobile tabs into buttons this.enhanceMobileTabs() @@ -40,7 +36,7 @@ class AppTabs { this.resetTabs() // Show the first panel already open if the `open` attribute is present - if (this.$module.hasAttribute('data-open')) { + if (this.$root.hasAttribute('data-open')) { this.openPanel(this.$panels[0].id) } } @@ -104,7 +100,7 @@ class AppTabs { }) // Replace the value of $mobileTabs with the new buttons - this.$mobileTabs = this.$module.querySelectorAll('.js-tabs__heading button') + this.$mobileTabs = this.$root.querySelectorAll('.js-tabs__heading button') } /** @@ -212,7 +208,7 @@ class AppTabs { * @returns {HTMLAnchorElement | null} Desktop tab link */ getDesktopTab(panelId) { - const $desktopTabContainer = this.$module.querySelector('.app-tabs') + const $desktopTabContainer = this.$root.querySelector('.app-tabs') if ($desktopTabContainer) { return $desktopTabContainer.querySelector(`[aria-controls="${panelId}"]`) } diff --git a/src/tsconfig.json b/src/tsconfig.json index 899a61af13..1eb13b59d7 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../tsconfig.base.json", - "include": ["**/*.js", "**/*.mjs"], + "include": ["**/*.js", "**/*.mjs", "**/*.ts"], "compilerOptions": { "lib": ["ESNext", "DOM"], "target": "ES2015" diff --git a/views/layouts/_generic.njk b/views/layouts/_generic.njk index a570490601..84850c14e8 100644 --- a/views/layouts/_generic.njk +++ b/views/layouts/_generic.njk @@ -3,6 +3,8 @@ {% set assetUrl = 'https://design-system.service.gov.uk/assets' %} +{% set bodyAttributes = { "data-module": "app-navigation" } %} + {% block pageTitle %}{{ title | smartypants }} – GOV.UK Design System{% endblock %} {% block head %}