diff --git a/bundle-base.tsconfig.json b/bundle-base.tsconfig.json index 73348d59..f2c7791e 100644 --- a/bundle-base.tsconfig.json +++ b/bundle-base.tsconfig.json @@ -14,6 +14,8 @@ "strict": true, "strictNullChecks": true, "resolveJsonModule": true, + "allowJs": true, + "outDir": "./dist", "paths": { "@components/*": ["src/components/*"], "@content-presentation/*": ["src/components/content-presentation/*"], diff --git a/rollup.config.mjs b/rollup.config.mjs index d3197c36..9f8db15a 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -64,6 +64,7 @@ export default [ declaration: true, declarationDir: 'dist/esm', emitDeclarationOnly: true, + outDir: 'dist/esm', }, }), preserveDirectives(), diff --git a/src/components/content-presentation/tabs/Tabs.tsx b/src/components/content-presentation/tabs/Tabs.tsx index b0764489..d7e1751b 100644 --- a/src/components/content-presentation/tabs/Tabs.tsx +++ b/src/components/content-presentation/tabs/Tabs.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import React, { FC, HTMLAttributes, useEffect } from 'react'; import HeadingLevel, { HeadingLevelType } from '@util/HeadingLevel'; -import TabsJs from 'nhsuk-frontend/packages/components/tabs/tabs'; +import TabsJs from './../../../scripts/tabs'; type TabsProps = HTMLAttributes; @@ -55,7 +55,8 @@ interface Tabs extends FC { const Tabs: Tabs = ({ className, children, ...rest }) => { useEffect(() => { - TabsJs.default ? TabsJs.default() : TabsJs(); + console.warn('Running tabs useeffect'); + TabsJs(); }, []); return ( diff --git a/src/components/form-elements/character-count/CharacterCount.tsx b/src/components/form-elements/character-count/CharacterCount.tsx index 0cc527ec..491d4abb 100644 --- a/src/components/form-elements/character-count/CharacterCount.tsx +++ b/src/components/form-elements/character-count/CharacterCount.tsx @@ -1,6 +1,6 @@ 'use client'; import React, { FC, useEffect } from 'react'; -import CharacterCountJs from 'nhsuk-frontend/packages/components/character-count/character-count'; +import CharacterCountJs from './../../../scripts/character-count'; import { HTMLAttributesWithData } from '@util/types/NHSUKTypes'; export enum CharacterCountType { @@ -25,7 +25,7 @@ const CharacterCount: FC = ({ ...rest }) => { useEffect(() => { - CharacterCountJs.default ? CharacterCountJs.default() : CharacterCountJs(); + CharacterCountJs(); }, []); const characterCountProps: HTMLAttributesWithData = diff --git a/src/components/form-elements/checkboxes/Checkboxes.tsx b/src/components/form-elements/checkboxes/Checkboxes.tsx index d75d1ce6..7ab0d937 100644 --- a/src/components/form-elements/checkboxes/Checkboxes.tsx +++ b/src/components/form-elements/checkboxes/Checkboxes.tsx @@ -8,7 +8,7 @@ import CheckboxContext, { ICheckboxContext } from './CheckboxContext'; import Box from './components/Box'; import Divider from './components/Divider'; import { generateRandomName } from '@util/RandomID'; -import CheckboxJs from 'nhsuk-frontend/packages/components/checkboxes/checkboxes'; +import CheckboxJs from './../../../scripts/checkboxes'; interface CheckboxesProps extends HTMLProps, FormElementProps { idPrefix?: string; @@ -20,7 +20,7 @@ const Checkboxes = ({ children, idPrefix, ...rest }: CheckboxesProps) => { let _boxIds: Record = {}; useEffect(() => { - CheckboxJs.default ? CheckboxJs.default() : CheckboxJs(); + CheckboxJs(); }, []); const getBoxId = (id: string, reference: string): string => { diff --git a/src/components/navigation/header/Header.tsx b/src/components/navigation/header/Header.tsx index 00f451aa..871e051e 100644 --- a/src/components/navigation/header/Header.tsx +++ b/src/components/navigation/header/Header.tsx @@ -11,7 +11,7 @@ import NavDropdownMenu from './components/NavDropdownMenu'; import { Container } from '@components/layout'; import Content from './components/Content'; import TransactionalServiceName from './components/TransactionalServiceName'; -import HeaderJs from 'nhsuk-frontend/packages/components/header/header'; +import HeaderJs from './../../../scripts/header'; const BaseHeaderLogo: FC = (props) => { const { orgName } = useContext(HeaderContext); @@ -51,7 +51,7 @@ const Header = ({ const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { - HeaderJs.default ? HeaderJs.default() : HeaderJs(); + HeaderJs(); }, []); const setMenuToggle = (toggle: boolean): void => { diff --git a/src/global.d.ts b/src/global.d.ts deleted file mode 100644 index 4d00cad0..00000000 --- a/src/global.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'nhsuk-frontend/packages/components/header/header'; -declare module 'nhsuk-frontend/packages/components/checkboxes/checkboxes'; -declare module 'nhsuk-frontend/packages/components/radios/radios'; -declare module 'nhsuk-frontend/packages/components/character-count/character-count'; -declare module 'nhsuk-frontend/packages/components/tabs/tabs'; diff --git a/src/scripts/character-count.js b/src/scripts/character-count.js new file mode 100644 index 00000000..2f6b3795 --- /dev/null +++ b/src/scripts/character-count.js @@ -0,0 +1,262 @@ +/* + * 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/scripts/checkboxes.js b/src/scripts/checkboxes.js new file mode 100644 index 00000000..e1135770 --- /dev/null +++ b/src/scripts/checkboxes.js @@ -0,0 +1,111 @@ +/* + * 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/scripts/common.js b/src/scripts/common.js new file mode 100644 index 00000000..4364f9cb --- /dev/null +++ b/src/scripts/common.js @@ -0,0 +1,42 @@ +/* + * 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/scripts/header.js b/src/scripts/header.js new file mode 100644 index 00000000..251aa5e2 --- /dev/null +++ b/src/scripts/header.js @@ -0,0 +1,216 @@ +/* + * 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/scripts/tabs.js b/src/scripts/tabs.js new file mode 100644 index 00000000..0b1e8cd4 --- /dev/null +++ b/src/scripts/tabs.js @@ -0,0 +1,334 @@ +/* + * 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 8fc20e78..a062939f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,8 @@ "strict": true, "strictNullChecks": true, "resolveJsonModule": true, + "allowJs": true, + "outDir": "./dist", "paths": { "@components/*": ["src/components/*"], "@content-presentation/*": ["src/components/content-presentation/*"],