From 919a3b6f9a472ddd2e429436f4c3f941fd9ccb3f Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 3 Jul 2025 11:49:11 +0100 Subject: [PATCH 1/5] Upgrade to NHS.UK frontend v9.6.3 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 671def70..e5ef5e82 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "jest": "^29.7.0", "jest-axe": "^8.0.0", "jest-environment-jsdom": "^29.7.0", - "nhsuk-frontend": "^9.0.1", + "nhsuk-frontend": "^9.6.3", "prettier": "^3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/yarn.lock b/yarn.lock index 96e463c0..73ded6d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9269,10 +9269,10 @@ __metadata: languageName: node linkType: hard -"nhsuk-frontend@npm:^9.0.1": - version: 9.0.1 - resolution: "nhsuk-frontend@npm:9.0.1" - checksum: 10c0/50a071be382807bb5c7b474d01f40d6cc59198cf601c9b52f168c7cc8d7e09c092d35dfea53bc654417cb07739b5fceb481e61aeb39a7c2621b54b78b82c8e0c +"nhsuk-frontend@npm:^9.6.3": + version: 9.6.3 + resolution: "nhsuk-frontend@npm:9.6.3" + checksum: 10c0/f130ad9596803b97b913693c503e468fa4e395fe89025b7af219e6c66b68944b35c5eb19fc6f3630cb9576bf2a73321912901b391dcea9fa80dfb8d3eb2ee451 languageName: node linkType: hard @@ -9321,7 +9321,7 @@ __metadata: jest: "npm:^29.7.0" jest-axe: "npm:^8.0.0" jest-environment-jsdom: "npm:^29.7.0" - nhsuk-frontend: "npm:^9.0.1" + nhsuk-frontend: "npm:^9.6.3" prettier: "npm:^3.2.5" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" From c6b5601b8baa01cf74e7738492dc4377bfe18be4 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 28 Apr 2025 16:54:51 +0100 Subject: [PATCH 2/5] Replace NHS.UK frontend CommonJS workarounds --- bundle-base.tsconfig.json | 3 +- .../content-presentation/tabs/Tabs.tsx | 3 +- .../character-count/CharacterCount.tsx | 3 +- .../form-elements/checkboxes/Checkboxes.tsx | 3 +- src/components/navigation/header/Header.tsx | 3 +- .../__snapshots__/Header.test.tsx.snap | 5 +- src/resources/character-count.js | 262 -------------- src/resources/checkboxes.js | 111 ------ src/resources/common.js | 42 --- src/resources/header.js | 216 ----------- src/resources/tabs.js | 334 ------------------ tsconfig.json | 3 +- 12 files changed, 11 insertions(+), 977 deletions(-) delete mode 100644 src/resources/character-count.js delete mode 100644 src/resources/checkboxes.js delete mode 100644 src/resources/common.js delete mode 100644 src/resources/header.js delete mode 100644 src/resources/tabs.js diff --git a/bundle-base.tsconfig.json b/bundle-base.tsconfig.json index 6bc40994..f2c7791e 100644 --- a/bundle-base.tsconfig.json +++ b/bundle-base.tsconfig.json @@ -23,8 +23,7 @@ "@navigation/*": ["src/components/navigation/*"], "@typography/*": ["src/components/typography/*"], "@util/*": ["src/util/*"], - "@patterns/*": ["src/patterns/*"], - "@resources/*": ["src/resources/*"] + "@patterns/*": ["src/patterns/*"] } }, "include": ["src"], diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index edfcf250..50db2fde 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -2,7 +2,8 @@ import classNames from 'classnames'; import React, { FC, HTMLAttributes, useEffect } from 'react'; import HeadingLevel, { HeadingLevelType } from '@components/utils/HeadingLevel'; -import TabsJs from '@resources/tabs'; +// @ts-expect-error -- No types available +import TabsJs from 'nhsuk-frontend/packages/components/tabs/tabs'; type TabsProps = HTMLAttributes; diff --git a/src/components/form-elements/character-count/CharacterCount.tsx b/src/components/form-elements/character-count/CharacterCount.tsx index e19ca0e9..f17acbb5 100644 --- a/src/components/form-elements/character-count/CharacterCount.tsx +++ b/src/components/form-elements/character-count/CharacterCount.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { FC, useEffect } from 'react'; -import CharacterCountJs from '@resources/character-count'; +// @ts-expect-error -- No types available +import CharacterCountJs from 'nhsuk-frontend/packages/components/character-count/character-count'; import { HTMLAttributesWithData } from '@util/types/NHSUKTypes'; export enum CharacterCountType { diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx index 4b10e37b..110168ed 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -8,7 +8,8 @@ import CheckboxContext, { ICheckboxContext } from './CheckboxContext'; import Box from './components/Box'; import Divider from './components/Divider'; import { generateRandomName } from '@util/RandomID'; -import CheckboxJs from '@resources/checkboxes'; +// @ts-expect-error -- No types available +import CheckboxJs from 'nhsuk-frontend/packages/components/checkboxes/checkboxes'; interface CheckboxesProps extends HTMLProps, FormElementProps { idPrefix?: string; diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index 4337f5e3..938d96e8 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -11,7 +11,8 @@ import NavDropdownMenu from './components/NavDropdownMenu'; import { Container } from '@components/layout'; import Content from './components/Content'; import TransactionalServiceName from './components/TransactionalServiceName'; -import HeaderJs from '@resources/header'; +// @ts-expect-error -- No types available +import HeaderJs from 'nhsuk-frontend/packages/components/header/header'; const BaseHeaderLogo: FC = (props) => { const { orgName } = useContext(HeaderContext); diff --git a/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap b/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap index 1bd88faa..66c77c98 100644 --- a/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap +++ b/src/components/navigation/header/__tests__/__snapshots__/Header.test.tsx.snap @@ -174,7 +174,7 @@ exports[`The header component Matches the snapshot 1`] = ` > -
diff --git a/src/resources/character-count.js b/src/resources/character-count.js deleted file mode 100644 index 2f6b3795..00000000 --- a/src/resources/character-count.js +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Lifted from nhsuk-frontend and brought into this repo to enable compilation to CJS if required - * See Github issue https://github.com/nhsuk/nhsuk-frontend/issues/937 - */ - -class CharacterCount { - constructor($module) { - this.$module = $module; - this.$textarea = $module.querySelector('.nhsuk-js-character-count'); - this.$visibleCountMessage = null; - this.$screenReaderCountMessage = null; - this.lastInputTimestamp = null; - } - - // Initialize component - init() { - // Check that required elements are present - if (!this.$textarea) { - return; - } - - // Check for module - const { $module } = this; - const { $textarea } = this; - const $fallbackLimitMessage = document.getElementById(`${$textarea.id}-info`); - - // Move the fallback count message to be immediately after the textarea - // Kept for backwards compatibility - $textarea.insertAdjacentElement('afterend', $fallbackLimitMessage); - - // Create the *screen reader* specific live-updating counter - // This doesn't need any styling classes, as it is never visible - const $screenReaderCountMessage = document.createElement('div'); - $screenReaderCountMessage.className = - 'nhsuk-character-count__sr-status nhsuk-u-visually-hidden'; - $screenReaderCountMessage.setAttribute('aria-live', 'polite'); - this.$screenReaderCountMessage = $screenReaderCountMessage; - $fallbackLimitMessage.insertAdjacentElement('afterend', $screenReaderCountMessage); - - // Create our live-updating counter element, copying the classes from the - // fallback element for backwards compatibility as these may have been configured - const $visibleCountMessage = document.createElement('div'); - $visibleCountMessage.className = $fallbackLimitMessage.className; - $visibleCountMessage.classList.add('nhsuk-character-count__status'); - $visibleCountMessage.setAttribute('aria-hidden', 'true'); - this.$visibleCountMessage = $visibleCountMessage; - $fallbackLimitMessage.insertAdjacentElement('afterend', $visibleCountMessage); - - // Hide the fallback limit message - $fallbackLimitMessage.classList.add('nhsuk-u-visually-hidden'); - - // Read options set using dataset ('data-' values) - this.options = CharacterCount.getDataset($module); - - // Determine the limit attribute (characters or words) - let countAttribute = this.defaults.characterCountAttribute; - if (this.options.maxwords) { - countAttribute = this.defaults.wordCountAttribute; - } - - // Save the element limit - this.maxLength = $module.getAttribute(countAttribute); - - // Check for limit - if (!this.maxLength) { - return; - } - - // Remove hard limit if set - $textarea.removeAttribute('maxlength'); - - this.bindChangeEvents(); - - // When the page is restored after navigating 'back' in some browsers the - // state of the character count is not restored until *after* the DOMContentLoaded - // event is fired, so we need to manually update it after the pageshow event - // in browsers that support it. - if ('onpageshow' in window) { - window.addEventListener('pageshow', this.updateCountMessage.bind(this)); - } else { - window.addEventListener('DOMContentLoaded', this.updateCountMessage.bind(this)); - } - this.updateCountMessage(); - } - - // Read data attributes - static getDataset(element) { - const dataset = {}; - const { attributes } = element; - if (attributes) { - for (let i = 0; i < attributes.length; i++) { - const attribute = attributes[i]; - const match = attribute.name.match(/^data-(.+)/); - if (match) { - dataset[match[1]] = attribute.value; - } - } - } - return dataset; - } - - // Counts characters or words in text - count(text) { - let length; - if (this.options.maxwords) { - const tokens = text.match(/\S+/g) || []; // Matches consecutive non-whitespace chars - length = tokens.length; // eslint-disable-line prefer-destructuring - } else { - length = text.length; // eslint-disable-line prefer-destructuring - } - return length; - } - - // Bind input propertychange to the elements and update based on the change - bindChangeEvents() { - const { $textarea } = this; - $textarea.addEventListener('keyup', this.handleKeyUp.bind(this)); - - // Bind focus/blur events to start/stop polling - $textarea.addEventListener('focus', this.handleFocus.bind(this)); - $textarea.addEventListener('blur', this.handleBlur.bind(this)); - } - - // Speech recognition software such as Dragon NaturallySpeaking will modify the - // fields by directly changing its `value`. These changes don't trigger events - // in JavaScript, so we need to poll to handle when and if they occur. - checkIfValueChanged() { - if (!this.$textarea.oldValue) { - this.$textarea.oldValue = ''; - } - if (this.$textarea.value !== this.$textarea.oldValue) { - this.$textarea.oldValue = this.$textarea.value; - this.updateCountMessage(); - } - } - - // Helper function to update both the visible and screen reader-specific - // counters simultaneously (e.g. on init) - updateCountMessage() { - this.updateVisibleCountMessage(); - this.updateScreenReaderCountMessage(); - } - - // Update visible counter - updateVisibleCountMessage() { - const { $textarea } = this; - const { $visibleCountMessage } = this; - const remainingNumber = this.maxLength - this.count($textarea.value); - - // If input is over the threshold, remove the disabled class which renders the - // counter invisible. - if (this.isOverThreshold()) { - $visibleCountMessage.classList.remove('nhsuk-character-count__message--disabled'); - } else { - $visibleCountMessage.classList.add('nhsuk-character-count__message--disabled'); - } - - // Update styles - if (remainingNumber < 0) { - $textarea.classList.add('nhsuk-textarea--error'); - $visibleCountMessage.classList.remove('nhsuk-hint'); - $visibleCountMessage.classList.add('nhsuk-error-message'); - } else { - $textarea.classList.remove('nhsuk-textarea--error'); - $visibleCountMessage.classList.remove('nhsuk-error-message'); - $visibleCountMessage.classList.add('nhsuk-hint'); - } - - // Update message - $visibleCountMessage.innerHTML = this.formattedUpdateMessage(); - } - - // Update screen reader-specific counter - updateScreenReaderCountMessage() { - const { $screenReaderCountMessage } = this; - - // If over the threshold, remove the aria-hidden attribute, allowing screen - // readers to announce the content of the element. - if (this.isOverThreshold()) { - $screenReaderCountMessage.removeAttribute('aria-hidden'); - } else { - $screenReaderCountMessage.setAttribute('aria-hidden', true); - } - - // Update message - $screenReaderCountMessage.innerHTML = this.formattedUpdateMessage(); - } - - // Format update message - formattedUpdateMessage() { - const { $textarea } = this; - const { options } = this; - const remainingNumber = this.maxLength - this.count($textarea.value); - - let charVerb = 'remaining'; - let charNoun = 'character'; - let displayNumber = remainingNumber; - if (options.maxwords) { - charNoun = 'word'; - } - charNoun += remainingNumber === -1 || remainingNumber === 1 ? '' : 's'; - - charVerb = remainingNumber < 0 ? 'too many' : 'remaining'; - displayNumber = Math.abs(remainingNumber); - - return `You have ${displayNumber} ${charNoun} ${charVerb}`; - } - - // Checks whether the value is over the configured threshold for the input. - // If there is no configured threshold, it is set to 0 and this function will - // always return true. - isOverThreshold() { - const { $textarea } = this; - const { options } = this; - - // Determine the remaining number of characters/words - const currentLength = this.count($textarea.value); - const { maxLength } = this; - - // Set threshold if presented in options - const thresholdPercent = options.threshold ? options.threshold : 0; - const thresholdValue = (maxLength * thresholdPercent) / 100; - - return thresholdValue <= currentLength; - } - - // Update the visible character counter and keep track of when the last update - // happened for each keypress - handleKeyUp() { - this.updateVisibleCountMessage(); - this.lastInputTimestamp = Date.now(); - } - - handleFocus() { - // If the field is focused, and a keyup event hasn't been detected for at - // least 1000 ms (1 second), then run the manual change check. - // This is so that the update triggered by the manual comparison doesn't - // conflict with debounced KeyboardEvent updates. - this.valueChecker = setInterval(() => { - if (!this.lastInputTimestamp || Date.now() - 500 >= this.lastInputTimestamp) { - this.checkIfValueChanged(); - } - }, 1000); - } - - handleBlur() { - // Cancel value checking on blur - clearInterval(this.valueChecker); - } -} - -CharacterCount.prototype.defaults = { - characterCountAttribute: 'data-maxlength', - wordCountAttribute: 'data-maxwords', -}; - -export default ({ scope = document } = {}) => { - const characterCounts = scope.querySelectorAll('[data-module="nhsuk-character-count"]'); - characterCounts.forEach((el) => { - new CharacterCount(el).init(); - }); -}; diff --git a/src/resources/checkboxes.js b/src/resources/checkboxes.js deleted file mode 100644 index e1135770..00000000 --- a/src/resources/checkboxes.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Lifted from nhsuk-frontend and brought into this repo to enable compilation to CJS if required - * See Github issue https://github.com/nhsuk/nhsuk-frontend/issues/937 - */ - -import { toggleConditionalInput } from './common.js'; - -/** - * Conditionally show content when a checkbox button is checked - * Test at http://0.0.0.0:3000/components/checkboxes/conditional.html - */ -const syncAllConditionalReveals = function syncAllConditionalReveals(input) { - const allInputsInForm = input.form.querySelectorAll('input[type="checkbox"]'); - allInputsInForm.forEach((item) => - toggleConditionalInput(item, 'nhsuk-checkboxes__conditional--hidden'), - ); -}; - -/** - * Uncheck other checkboxes - * - * Find any other checkbox inputs with the checkbox group value, and uncheck them. - * This is useful for when a “None of these" checkbox is checked. - */ -const unCheckAllInputsExcept = function unCheckAllInputsExcept(input) { - const allInputsInSameExclusiveGroup = input.form.querySelectorAll( - `input[type="checkbox"][data-checkbox-exclusive-group="${input.getAttribute('data-checkbox-exclusive-group')}"]`, - ); - - allInputsInSameExclusiveGroup.forEach((inputWithSameName) => { - const hasSameFormOwner = input.form === inputWithSameName.form; - if (hasSameFormOwner && inputWithSameName !== input) { - inputWithSameName.checked = false; // eslint-disable-line no-param-reassign - } - }); - - syncAllConditionalReveals(input); -}; - -/** - * Uncheck exclusive inputs - * - * Find any checkbox inputs with the same checkbox group value and the 'exclusive' behaviour, - * and uncheck them. This helps prevent someone checking both a regular checkbox and a - * "None of these" checkbox in the same fieldset. - */ -const unCheckExclusiveInputs = function unCheckExclusiveInputs(input) { - const allExclusiveInputsInSameExclusiveGroup = input.form.querySelectorAll( - `input[type="checkbox"][data-checkbox-exclusive][data-checkbox-exclusive-group="${input.getAttribute( - 'data-checkbox-exclusive-group', - )}"]`, - ); - - allExclusiveInputsInSameExclusiveGroup.forEach((exclusiveInput) => { - const hasSameFormOwner = input.form === exclusiveInput.form; - if (hasSameFormOwner) { - exclusiveInput.checked = false; // eslint-disable-line no-param-reassign - } - }); - - syncAllConditionalReveals(input); -}; - -export default ({ scope = document } = {}) => { - // Checkbox input DOMElements inside a conditional form group - const checkboxInputs = scope.querySelectorAll('.nhsuk-checkboxes .nhsuk-checkboxes__input'); - - /** - * Toggle classes and attributes - * @param {Object} event click event object - */ - const handleClick = (event) => { - // Toggle conditional content based on checked state - toggleConditionalInput(event.target, 'nhsuk-checkboxes__conditional--hidden'); - - if (!event.target.checked) { - return; - } - - // Handle 'exclusive' checkbox behaviour (ie "None of these") - if (event.target.hasAttribute('data-checkbox-exclusive')) { - unCheckAllInputsExcept(event.target); - } else { - unCheckExclusiveInputs(event.target); - } - }; - - // When the page is restored after navigating 'back' in some browsers the - // state of form controls is not restored until *after* the DOMContentLoaded - // event is fired, so we need to sync after the pageshow event in browsers - // that support it. - if ('onpageshow' in window) { - window.addEventListener('pageshow', () => - checkboxInputs.forEach((input) => syncAllConditionalReveals(input)), - ); - } else { - window.addEventListener('DOMContentLoaded', () => - checkboxInputs.forEach((input) => syncAllConditionalReveals(input)), - ); - } - - // Although we've set up handlers to sync state on the pageshow or - // DOMContentLoaded event, init could be called after those events have fired, - // for example if they are added to the page dynamically, so sync now too. - checkboxInputs.forEach((input) => syncAllConditionalReveals(input)); - - // Attach handleClick as click to checkboxInputs - checkboxInputs.forEach((checkboxButton) => { - checkboxButton.addEventListener('change', handleClick); - }); -}; diff --git a/src/resources/common.js b/src/resources/common.js deleted file mode 100644 index 4364f9cb..00000000 --- a/src/resources/common.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Lifted from nhsuk-frontend and brought into this repo to enable compilation to CJS if required - * See Github issue https://github.com/nhsuk/nhsuk-frontend/issues/937 - */ - -/** - * Toggle a boolean attribute on a HTML element - * @param {HTMLElement} element - * @param {string} attr - */ -export const toggleAttribute = (element, attr) => { - // Return without error if element or attr are missing - if (!element || !attr) return; - // Toggle attribute value. Treat no existing attr same as when set to false - const value = element.getAttribute(attr) === 'true' ? 'false' : 'true'; - element.setAttribute(attr, value); -}; - -/** - * Toggle a toggle a class on conditional content for an input based on checked state - * @param {HTMLElement} input input element - * @param {string} className class to toggle - */ -export const toggleConditionalInput = (input, className) => { - // Return without error if input or class are missing - if (!input || !className) return; - // If the input has conditional content it had a data-aria-controls attribute - const conditionalId = input.getAttribute('aria-controls'); - if (conditionalId) { - // Get the conditional element from the input data-aria-controls attribute - const conditionalElement = document.getElementById(conditionalId); - if (conditionalElement) { - if (input.checked) { - conditionalElement.classList.remove(className); - input.setAttribute('aria-expanded', true); - } else { - conditionalElement.classList.add(className); - input.setAttribute('aria-expanded', false); - } - } - } -}; diff --git a/src/resources/header.js b/src/resources/header.js deleted file mode 100644 index 251aa5e2..00000000 --- a/src/resources/header.js +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Lifted from nhsuk-frontend and brought into this repo to enable compilation to CJS if required - * See Github issue https://github.com/nhsuk/nhsuk-frontend/issues/937 - */ - -class Header { - constructor() { - this.menuIsOpen = false; - this.navigation = document.querySelector('.nhsuk-navigation'); - this.navigationList = document.querySelector('.nhsuk-header__navigation-list'); - this.mobileMenu = document.createElement('ul'); - this.mobileMenuToggleButton = document.querySelector('.nhsuk-header__menu-toggle'); - this.mobileMenuCloseButton = document.createElement('button'); - this.mobileMenuContainer = document.querySelector('.nhsuk-mobile-menu-container'); - this.breakpoints = []; - this.width = document.body.offsetWidth; - } - - init() { - if ( - !this.navigation || - !this.navigationList || - !this.mobileMenuToggleButton || - !this.mobileMenuContainer - ) { - return; - } - - this.setupMobileMenu(); - this.calculateBreakpoints(); - this.updateNavigation(); - this.doOnOrientationChange(); - - this.handleResize = this.debounce(() => { - this.calculateBreakpoints(); - this.updateNavigation(); - }); - - this.mobileMenuToggleButton.addEventListener('click', this.toggleMobileMenu.bind(this)); - window.addEventListener('resize', this.handleResize); - window.addEventListener('orientationchange', this.doOnOrientationChange()); - } - - debounce(func, timeout = 100) { - let timer; - return (...args) => { - clearTimeout(timer); - timer = setTimeout(() => { - func.apply(this, args); - }, timeout); - }; - } - - /** - * Calculate breakpoints. - * - * Calculate the breakpoints by summing the widths of - * each navigation item. - * - */ - calculateBreakpoints() { - let childrenWidth = 0; - for (let i = 0; i < this.navigationList.children.length; i++) { - childrenWidth += this.navigationList.children[i].offsetWidth; - this.breakpoints[i] = childrenWidth; - } - } - - // Add the mobile menu to the DOM - setupMobileMenu() { - this.mobileMenuContainer.appendChild(this.mobileMenu); - this.mobileMenu.classList.add('nhsuk-header__drop-down', 'nhsuk-header__drop-down--hidden'); - } - - /** - * Close the mobile menu - * - * Closes the mobile menu and updates accessibility state. - * - * Remvoes the margin-bottom from the navigation - */ - closeMobileMenu() { - this.menuIsOpen = false; - this.mobileMenu.classList.add('nhsuk-header__drop-down--hidden'); - this.navigation.style.marginBottom = 0; - this.mobileMenuToggleButton.setAttribute('aria-expanded', 'false'); - this.mobileMenuToggleButton.focus(); - this.mobileMenuCloseButton.removeEventListener('click', this.closeMobileMenu.bind(this)); - document.removeEventListener('keydown', this.handleEscapeKey.bind(this)); - } - - /** - * Escape key handler - * - * This function is called when the user - * presses the escape key to close the mobile menu. - * - */ - handleEscapeKey(e) { - if (e.key === 'Escape') { - this.closeMobileMenu(); - } - } - - /** - * Open the mobile menu - * - * Opens the mobile menu and updates accessibility state. - * - * The mobile menu is absolutely positioned, so it adds a margin - * to the bottom of the navigation to prevent it from overlapping - * - * Adds event listeners for the close button, - */ - - openMobileMenu() { - this.menuIsOpen = true; - this.mobileMenu.classList.remove('nhsuk-header__drop-down--hidden'); - const marginBody = this.mobileMenu.offsetHeight; - this.navigation.style.marginBottom = `${marginBody}px`; - this.mobileMenuToggleButton.setAttribute('aria-expanded', 'true'); - - // add event listerer for esc key to close menu - document.addEventListener('keydown', this.handleEscapeKey.bind(this)); - - // add event listener for close icon to close menu - this.mobileMenuCloseButton.addEventListener('click', this.closeMobileMenu.bind(this)); - } - - /** - * Handle menu button click - * - * Toggles the mobile menu between open and closed - */ - toggleMobileMenu() { - if (this.menuIsOpen) { - this.closeMobileMenu(); - } else { - this.openMobileMenu(); - } - } - - /** - * Update nav for the available space - * - * If the available space is less than the current breakpoint, - * add the mobile menu toggle button and move the last - * item in the list to the drop-down list. - * - * If the available space is greater than the current breakpoint, - * remove the mobile menu toggle button and move the first item in the - * - * Additionaly will close the mobile menu if the window gets resized - * and the menu is open. - */ - - updateNavigation() { - const availableSpace = this.navigation.offsetWidth; - let itemsVisible = this.navigationList.children.length; - - if (availableSpace < this.breakpoints[itemsVisible - 1]) { - this.mobileMenuToggleButton.classList.add('nhsuk-header__menu-toggle--visible'); - this.mobileMenuContainer.classList.add('nhsuk-mobile-menu-container--visible'); - if (itemsVisible === 2) { - return; - } - while (availableSpace < this.breakpoints[itemsVisible - 1]) { - this.mobileMenu.insertBefore( - this.navigationList.children[itemsVisible - 2], - this.mobileMenu.firstChild, - ); - itemsVisible -= 1; - } - } else if (availableSpace > this.breakpoints[itemsVisible]) { - while (availableSpace > this.breakpoints[itemsVisible]) { - this.navigationList.insertBefore( - this.mobileMenu.removeChild(this.mobileMenu.firstChild), - this.mobileMenuContainer, - ); - itemsVisible += 1; - } - } - - if (!this.mobileMenu.children.length) { - this.mobileMenuToggleButton.classList.remove('nhsuk-header__menu-toggle--visible'); - this.mobileMenuContainer.classList.remove('nhsuk-mobile-menu-container--visible'); - } - - if (document.body.offsetWidth !== this.width && this.menuIsOpen) { - this.closeMobileMenu(); - } - } - - /** - * Orientation change - * - * Check the orientation of the device, if changed it will trigger a - * update to the breakpoints and navigation. - */ - doOnOrientationChange() { - switch (window.orientation) { - case 90: - setTimeout(() => { - this.calculateBreakpoints(); - this.updateNavigation(); - }, 200); - break; - default: - break; - } - } -} - -export default () => { - new Header().init(); -}; diff --git a/src/resources/tabs.js b/src/resources/tabs.js deleted file mode 100644 index 0b1e8cd4..00000000 --- a/src/resources/tabs.js +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Lifted from nhsuk-frontend and brought into this repo to enable compilation to CJS if required - * See Github issue https://github.com/nhsuk/nhsuk-frontend/issues/937 - */ -class Tabs { - constructor($module, namespace, responsive, historyEnabled) { - this.$module = $module; - this.namespace = namespace; - this.responsive = responsive; - this.historyEnabled = historyEnabled; - this.$tabs = $module.querySelectorAll(`.${this.namespace}__tab`); - - this.keys = { - down: 40, - left: 37, - right: 39, - up: 38, - }; - this.jsHiddenClass = `${this.namespace}__panel--hidden`; - - this.showEvent = new CustomEvent('tab.show'); - this.hideEvent = new CustomEvent('tab.hide'); - } - - init() { - if (typeof window.matchMedia === 'function' && this.responsive) { - this.setupResponsiveChecks(); - } else { - this.setup(); - } - } - - setupResponsiveChecks() { - // $mq-breakpoints: ( - // mobile: 320px, - // tablet: 641px, - // desktop: 769px, - // large - desktop: 990px - // ); - this.mql = window.matchMedia('(min-width: 641px)'); - this.mql.addEventListener('change', this.checkMode.bind(this)); - this.checkMode(); - } - - checkMode() { - if (this.mql.matches) { - this.setup(); - } else { - this.teardown(); - } - } - - setup() { - const { $module } = this; - const { $tabs } = this; - const $tabList = $module.querySelector(`.${this.namespace}__list`); - const $tabListItems = $module.querySelectorAll(`.${this.namespace}__list-item`); - - if (!$tabs || !$tabList || !$tabListItems) { - return; - } - - $tabList.setAttribute('role', 'tablist'); - - $tabListItems.forEach(($item) => { - $item.setAttribute('role', 'presentation'); - }); - - $tabs.forEach(($tab) => { - // Set HTML attributes - this.setAttributes($tab); - - // Save bounded functions to use when removing event listeners during teardown - // eslint-disable-next-line no-param-reassign - $tab.boundTabClick = this.onTabClick.bind(this); - // eslint-disable-next-line no-param-reassign - $tab.boundTabKeydown = this.onTabKeydown.bind(this); - - // Handle events - $tab.addEventListener('click', $tab.boundTabClick, true); - $tab.addEventListener('keydown', $tab.boundTabKeydown, true); - - // Remove old active panels - this.hideTab($tab); - }); - - // Show either the active tab according to the URL's hash or the first tab - const $activeTab = this.getTab(window.location.hash) || this.$tabs[0]; - this.showTab($activeTab); - - // Handle hashchange events - if (this.historyEnabled) { - $module.boundOnHashChange = this.onHashChange.bind(this); - window.addEventListener('hashchange', $module.boundOnHashChange, true); - } - } - - teardown() { - const { $module } = this; - const { $tabs } = this; - const $tabList = $module.querySelector(`.${this.namespace}__list`); - const $tabListItems = $module.querySelectorAll(`.${this.namespace}__list-item`); - - if (!$tabs || !$tabList || !$tabListItems) { - return; - } - - $tabList.removeAttribute('role'); - - $tabListItems.forEach(($item) => { - $item.removeAttribute('role', 'presentation'); - }); - - $tabs.forEach(($tab) => { - // Remove events - $tab.removeEventListener('click', $tab.boundTabClick, true); - $tab.removeEventListener('keydown', $tab.boundTabKeydown, true); - - // Unset HTML attributes - this.unsetAttributes($tab); - }); - - if (this.historyEnabled) { - // Remove hashchange event handler - window.removeEventListener('hashchange', $module.boundOnHashChange, true); - } - } - - onHashChange() { - const { hash } = window.location; - const $tabWithHash = this.getTab(hash); - if (!$tabWithHash) { - return; - } - - // Prevent changing the hash - if (this.changingHash) { - this.changingHash = false; - return; - } - - // Show either the active tab according to the URL's hash or the first tab - const $previousTab = this.getCurrentTab(); - - this.hideTab($previousTab); - this.showTab($tabWithHash); - $tabWithHash.focus(); - } - - hideTab($tab) { - this.unhighlightTab($tab); - this.hidePanel($tab); - } - - showTab($tab) { - this.highlightTab($tab); - this.showPanel($tab); - } - - getTab(hash) { - return this.$module.querySelector(`.${this.namespace}__tab[href="${hash}"]`); - } - - setAttributes($tab) { - // set tab attributes - const panelId = Tabs.getHref($tab).slice(1); - $tab.setAttribute('id', `tab_${panelId}`); - $tab.setAttribute('role', 'tab'); - $tab.setAttribute('aria-controls', panelId); - $tab.setAttribute('aria-selected', 'false'); - $tab.setAttribute('tabindex', '-1'); - - // set panel attributes - const $panel = this.getPanel($tab); - $panel.setAttribute('role', 'tabpanel'); - $panel.setAttribute('aria-labelledby', $tab.id); - $panel.classList.add(this.jsHiddenClass); - } - - unsetAttributes($tab) { - // unset tab attributes - $tab.removeAttribute('id'); - $tab.removeAttribute('role'); - $tab.removeAttribute('aria-controls'); - $tab.removeAttribute('aria-selected'); - $tab.removeAttribute('tabindex'); - - // unset panel attributes - const $panel = this.getPanel($tab); - $panel.removeAttribute('role'); - $panel.removeAttribute('aria-labelledby'); - $panel.removeAttribute('tabindex'); - $panel.classList.remove(this.jsHiddenClass); - } - - onTabClick(e) { - if (!e.target.classList.contains(`${this.namespace}__tab`)) { - e.stopPropagation(); - e.preventDefault(); - } - e.preventDefault(); - const $newTab = e.target; - const $currentTab = this.getCurrentTab(); - this.hideTab($currentTab); - this.showTab($newTab); - this.createHistoryEntry($newTab); - } - - createHistoryEntry($tab) { - if (this.historyEnabled) { - const $panel = this.getPanel($tab); - - // Save and restore the id - // so the page doesn't jump when a user clicks a tab (which changes the hash) - const { id } = $panel; - $panel.id = ''; - this.changingHash = true; - window.location.hash = Tabs.getHref($tab).slice(1); - $panel.id = id; - } - } - - onTabKeydown(e) { - switch (e.keyCode) { - case this.keys.left: - case this.keys.up: - this.activatePreviousTab(); - e.preventDefault(); - break; - case this.keys.right: - case this.keys.down: - this.activateNextTab(); - e.preventDefault(); - break; - - default: - } - } - - activateNextTab() { - const currentTab = this.getCurrentTab(); - const nextTabListItem = currentTab.parentNode.nextElementSibling; - let nextTab; - - if (nextTabListItem) { - nextTab = nextTabListItem.querySelector(`.${this.namespace}__tab`); - } - if (nextTab) { - this.hideTab(currentTab); - this.showTab(nextTab); - nextTab.focus(); - this.createHistoryEntry(nextTab); - } - } - - activatePreviousTab() { - const currentTab = this.getCurrentTab(); - const previousTabListItem = currentTab.parentNode.previousElementSibling; - let previousTab; - - if (previousTabListItem) { - previousTab = previousTabListItem.querySelector(`.${this.namespace}__tab`); - } - if (previousTab) { - this.hideTab(currentTab); - this.showTab(previousTab); - previousTab.focus(); - this.createHistoryEntry(previousTab); - } - } - - getPanel($tab) { - const $panel = this.$module.querySelector(Tabs.getHref($tab)); - return $panel; - } - - showPanel($tab) { - const $panel = this.getPanel($tab); - $panel.classList.remove(this.jsHiddenClass); - $panel.dispatchEvent(this.showEvent); - } - - hidePanel(tab) { - const $panel = this.getPanel(tab); - $panel.classList.add(this.jsHiddenClass); - $panel.dispatchEvent(this.hideEvent); - } - - unhighlightTab($tab) { - $tab.setAttribute('aria-selected', 'false'); - $tab.parentNode.classList.remove(`${this.namespace}__list-item--selected`); - $tab.setAttribute('tabindex', '-1'); - } - - highlightTab($tab) { - $tab.setAttribute('aria-selected', 'true'); - $tab.parentNode.classList.add(`${this.namespace}__list-item--selected`); - $tab.setAttribute('tabindex', '0'); - } - - getCurrentTab() { - return this.$module.querySelector( - `.${this.namespace}__list-item--selected .${this.namespace}__tab`, - ); - } - - // this is because IE doesn't always return the actual value but a relative full path - // should be a utility function most prob - // http://labs.thesedays.com/blog/2010/01/08/getting-the-href-value-with-jquery-in-ie/ - static getHref($tab) { - const href = $tab.getAttribute('href'); - const hash = href.slice(href.indexOf('#'), href.length); - return hash; - } -} - -/** - * Main function to invoke tabs. Can be called as follows to alter various features - * - * Tabs({historyEnabled: false}); - * Tabs({responsive: false}); - * Tabs({namespace: 'my-custom-namespace'}); // Alters classes allowing alternative css - */ -export default ({ - namespace = 'nhsuk-tabs', - responsive = true, - historyEnabled = true, - scope = document, -} = {}) => { - const tabs = scope.querySelectorAll(`[data-module="${namespace}"]`); - tabs.forEach((el) => { - new Tabs(el, namespace, responsive, historyEnabled).init(); - }); -}; diff --git a/tsconfig.json b/tsconfig.json index 12f84ec7..a062939f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,8 +24,7 @@ "@navigation/*": ["src/components/navigation/*"], "@typography/*": ["src/components/typography/*"], "@util/*": ["src/util/*"], - "@patterns/*": ["src/patterns/*"], - "@resources/*": ["src/resources/*"] + "@patterns/*": ["src/patterns/*"] } }, "include": ["src", "stories"], From 4a2bd34f31de60b528d0325a0a94c1ad69e978ed Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 3 Jul 2025 11:50:20 +0100 Subject: [PATCH 3/5] Prevent unnecessary Babel transforms --- jest.config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 32cf0d8c..4c0cc8c5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,7 +19,6 @@ const jestConfig = { }, ], }, - transformIgnorePatterns: ['node_modules/(?!nhsuk-frontend/packages)'], }; module.exports = jestConfig; From 6b4ba2ab85c1f305f46ae167d3e7cd52e31f53a7 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 3 Jul 2025 12:00:48 +0100 Subject: [PATCH 4/5] Rename NHS.UK frontend init functions --- src/components/content-presentation/tabs/Tabs.tsx | 4 ++-- .../form-elements/character-count/CharacterCount.tsx | 4 ++-- src/components/form-elements/checkboxes/Checkboxes.tsx | 4 ++-- src/components/navigation/header/Header.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index 50db2fde..629f237e 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import React, { FC, HTMLAttributes, useEffect } from 'react'; import HeadingLevel, { HeadingLevelType } from '@components/utils/HeadingLevel'; // @ts-expect-error -- No types available -import TabsJs from 'nhsuk-frontend/packages/components/tabs/tabs'; +import initTabs from 'nhsuk-frontend/packages/components/tabs/tabs'; type TabsProps = HTMLAttributes; @@ -56,7 +56,7 @@ interface Tabs extends FC { const Tabs: Tabs = ({ className, children, ...rest }) => { useEffect(() => { - TabsJs(); + initTabs(); }, []); return ( diff --git a/src/components/form-elements/character-count/CharacterCount.tsx b/src/components/form-elements/character-count/CharacterCount.tsx index f17acbb5..223841ed 100644 --- a/src/components/form-elements/character-count/CharacterCount.tsx +++ b/src/components/form-elements/character-count/CharacterCount.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { FC, useEffect } from 'react'; // @ts-expect-error -- No types available -import CharacterCountJs from 'nhsuk-frontend/packages/components/character-count/character-count'; +import initCharacterCounts from 'nhsuk-frontend/packages/components/character-count/character-count'; import { HTMLAttributesWithData } from '@util/types/NHSUKTypes'; export enum CharacterCountType { @@ -26,7 +26,7 @@ const CharacterCount: FC = ({ ...rest }) => { useEffect(() => { - CharacterCountJs(); + initCharacterCounts(); }, []); const characterCountProps: HTMLAttributesWithData = diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx index 110168ed..b9936fdd 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -9,7 +9,7 @@ import Box from './components/Box'; import Divider from './components/Divider'; import { generateRandomName } from '@util/RandomID'; // @ts-expect-error -- No types available -import CheckboxJs from 'nhsuk-frontend/packages/components/checkboxes/checkboxes'; +import initCheckboxes from 'nhsuk-frontend/packages/components/checkboxes/checkboxes'; interface CheckboxesProps extends HTMLProps, FormElementProps { idPrefix?: string; @@ -21,7 +21,7 @@ const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => { let _boxIds: Record = {}; useEffect(() => { - CheckboxJs(); + initCheckboxes(); }, []); const getBoxId = (id: string, reference: string): string => { diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index 938d96e8..0dfe1e71 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -12,7 +12,7 @@ import { Container } from '@components/layout'; import Content from './components/Content'; import TransactionalServiceName from './components/TransactionalServiceName'; // @ts-expect-error -- No types available -import HeaderJs from 'nhsuk-frontend/packages/components/header/header'; +import initHeader from 'nhsuk-frontend/packages/components/header/header'; const BaseHeaderLogo: FC = (props) => { const { orgName } = useContext(HeaderContext); @@ -53,7 +53,7 @@ const Header = ({ const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { - HeaderJs(); + initHeader(); }, []); const setMenuToggle = (toggle: boolean): void => { From d1f54802af474f98f1fd646a8b795ef97a5f539a Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 3 Jul 2025 11:44:07 +0100 Subject: [PATCH 5/5] Prevent duplicate initialisation --- .../content-presentation/tabs/Tabs.tsx | 21 +++++++++++++++---- .../character-count/CharacterCount.tsx | 15 ++++++++++--- .../form-elements/checkboxes/Checkboxes.tsx | 21 +++++++++++++++---- src/components/navigation/header/Header.tsx | 15 ++++++++++--- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index 629f237e..68cdb87b 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -1,6 +1,6 @@ 'use client'; import classNames from 'classnames'; -import React, { FC, HTMLAttributes, useEffect } from 'react'; +import React, { FC, HTMLAttributes, useEffect, useRef, useState } from 'react'; import HeadingLevel, { HeadingLevelType } from '@components/utils/HeadingLevel'; // @ts-expect-error -- No types available import initTabs from 'nhsuk-frontend/packages/components/tabs/tabs'; @@ -55,12 +55,25 @@ interface Tabs extends FC { } const Tabs: Tabs = ({ className, children, ...rest }) => { + const moduleRef = useRef(null); + const [isInitialised, setIsInitialised] = useState(false); + useEffect(() => { - initTabs(); - }, []); + if (isInitialised || !moduleRef.current?.parentElement) { + return; + } + + initTabs({ scope: moduleRef.current.parentElement }); + setIsInitialised(true); + }, [isInitialised, moduleRef]); return ( -
+
{children}
); diff --git a/src/components/form-elements/character-count/CharacterCount.tsx b/src/components/form-elements/character-count/CharacterCount.tsx index 223841ed..20938cc4 100644 --- a/src/components/form-elements/character-count/CharacterCount.tsx +++ b/src/components/form-elements/character-count/CharacterCount.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { FC, useEffect } from 'react'; +import React, { FC, useEffect, useRef, useState } from 'react'; // @ts-expect-error -- No types available import initCharacterCounts from 'nhsuk-frontend/packages/components/character-count/character-count'; import { HTMLAttributesWithData } from '@util/types/NHSUKTypes'; @@ -25,9 +25,17 @@ const CharacterCount: FC = ({ thresholdPercent, ...rest }) => { + const moduleRef = useRef(null); + const [isInitialised, setIsInitialised] = useState(false); + useEffect(() => { - initCharacterCounts(); - }, []); + if (isInitialised || !moduleRef.current?.parentElement) { + return; + } + + initCharacterCounts({ scope: moduleRef.current.parentElement }); + setIsInitialised(true); + }, [isInitialised, moduleRef]); const characterCountProps: HTMLAttributesWithData = countType === CharacterCountType.Characters @@ -42,6 +50,7 @@ const CharacterCount: FC = ({
{children}
diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx index b9936fdd..74de3cc9 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { HTMLProps, useEffect } from 'react'; +import React, { HTMLProps, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { FormElementProps } from '@util/types/FormTypes'; import SingleInputFormGroup from '@components/utils/SingleInputFormGroup'; @@ -16,13 +16,21 @@ interface CheckboxesProps extends HTMLProps, FormElementProps { } const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => { + const moduleRef = useRef(null); + const [isInitialised, setIsInitialised] = useState(false); + const _boxReferences: string[] = []; let _boxCount: number = 0; let _boxIds: Record = {}; useEffect(() => { - initCheckboxes(); - }, []); + if (isInitialised || !moduleRef.current?.parentElement) { + return; + } + + initCheckboxes({ scope: moduleRef.current.parentElement }); + setIsInitialised(true); + }, [isInitialised, moduleRef]); const getBoxId = (id: string, reference: string): string => { if (reference in _boxIds) { @@ -65,7 +73,12 @@ const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => { unleaseReference, }; return ( -
+
{children}
); diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index 0dfe1e71..95836f2a 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { FC, HTMLProps, useContext, useState, useEffect, useMemo } from 'react'; +import React, { FC, HTMLProps, useContext, useState, useEffect, useMemo, useRef } from 'react'; import classNames from 'classnames'; import NHSLogo, { NHSLogoNavProps } from './components/NHSLogo'; import OrganisationalLogo, { OrganisationalLogoProps } from './components/OrganisationalLogo'; @@ -47,14 +47,22 @@ const Header = ({ white, ...rest }: HeaderProps) => { + const moduleRef = useRef(null); + const [hasMenuToggle, setHasMenuToggle] = useState(false); const [hasSearch, setHasSearch] = useState(false); const [hasServiceName, setHasServiceName] = useState(false); + const [isInitialised, setIsInitialised] = useState(false); const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { - initHeader(); - }, []); + if (isInitialised || !moduleRef.current?.parentElement) { + return; + } + + initHeader({ scope: moduleRef.current.parentElement }); + setIsInitialised(true); + }, [isInitialised, moduleRef]); const setMenuToggle = (toggle: boolean): void => { setHasMenuToggle(toggle); @@ -114,6 +122,7 @@ const Header = ({ className, )} role={role} + ref={moduleRef} {...rest} > {children}