From eac5b1cc787439265a1acf48478188f76cea7d56 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Thu, 8 Jun 2023 16:39:19 +0100 Subject: [PATCH 1/9] Remove ESLint rule allowing unknown `.prototype` access --- packages/govuk-frontend/.eslintrc.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/govuk-frontend/.eslintrc.js b/packages/govuk-frontend/.eslintrc.js index 64b308b2d2..3def3dc12a 100644 --- a/packages/govuk-frontend/.eslintrc.js +++ b/packages/govuk-frontend/.eslintrc.js @@ -29,9 +29,6 @@ module.exports = { browser: true }, rules: { - // Allow unknown `.prototype` members until ES2015 classes - '@typescript-eslint/no-unsafe-member-access': 'off', - // Check type support for template string implicit `.toString()` '@typescript-eslint/restrict-template-expressions': [ 'error', From 345f3021a4a1797787a609363c9715d664d70897 Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Fri, 9 Jun 2023 10:03:10 +0100 Subject: [PATCH 2/9] Convert all components to ES2015 classes --- .../govuk/components/accordion/accordion.mjs | 906 +++++++++--------- .../src/govuk/components/button/button.mjs | 173 ++-- .../character-count/character-count.mjs | 660 ++++++------- .../components/checkboxes/checkboxes.mjs | 330 +++---- .../src/govuk/components/details/details.mjs | 245 ++--- .../error-summary/error-summary.mjs | 355 +++---- .../src/govuk/components/header/header.mjs | 182 ++-- .../notification-banner.mjs | 124 +-- .../src/govuk/components/radios/radios.mjs | 240 ++--- .../govuk/components/skip-link/skip-link.mjs | 183 ++-- .../src/govuk/components/tabs/tabs.mjs | 898 ++++++++--------- 11 files changed, 2150 insertions(+), 2146 deletions(-) diff --git a/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs b/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs index f29881a7e3..92ecb06d77 100644 --- a/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs +++ b/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs @@ -30,474 +30,539 @@ const ACCORDION_TRANSLATIONS = { * * The state of each section is saved to the DOM via the `aria-expanded` * attribute, which also provides accessibility. - * - * @class - * @param {Element} $module - HTML element to use for accordion - * @param {AccordionConfig} [config] - Accordion config */ -function Accordion ($module, config) { - if (!($module instanceof HTMLElement)) { - return this - } +export default class Accordion { + /** + * @param {Element} $module - HTML element to use for accordion + * @param {AccordionConfig} [config] - Accordion config + */ + constructor ($module, config) { + if (!($module instanceof HTMLElement)) { + return this + } - /** @deprecated Will be made private in v5.0 */ - this.$module = $module + /** @deprecated Will be made private in v5.0 */ + this.$module = $module - /** @type {AccordionConfig} */ - const defaultConfig = { - i18n: ACCORDION_TRANSLATIONS, - rememberExpanded: true - } + /** @type {AccordionConfig} */ + const defaultConfig = { + i18n: ACCORDION_TRANSLATIONS, + rememberExpanded: true + } - /** - * @deprecated Will be made private in v5.0 - * @type {AccordionConfig} - */ - this.config = mergeConfigs( - defaultConfig, - config || {}, - normaliseDataset($module.dataset) - ) + /** + * @deprecated Will be made private in v5.0 + * @type {AccordionConfig} + */ + this.config = mergeConfigs( + defaultConfig, + config || {}, + normaliseDataset($module.dataset) + ) - /** @deprecated Will be made private in v5.0 */ - this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n')) + /** @deprecated Will be made private in v5.0 */ + this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n')) - /** @deprecated Will be made private in v5.0 */ - this.controlsClass = 'govuk-accordion__controls' + /** @deprecated Will be made private in v5.0 */ + this.controlsClass = 'govuk-accordion__controls' - /** @deprecated Will be made private in v5.0 */ - this.showAllClass = 'govuk-accordion__show-all' + /** @deprecated Will be made private in v5.0 */ + this.showAllClass = 'govuk-accordion__show-all' - /** @deprecated Will be made private in v5.0 */ - this.showAllTextClass = 'govuk-accordion__show-all-text' + /** @deprecated Will be made private in v5.0 */ + this.showAllTextClass = 'govuk-accordion__show-all-text' - /** @deprecated Will be made private in v5.0 */ - this.sectionClass = 'govuk-accordion__section' + /** @deprecated Will be made private in v5.0 */ + this.sectionClass = 'govuk-accordion__section' - /** @deprecated Will be made private in v5.0 */ - this.sectionExpandedClass = 'govuk-accordion__section--expanded' + /** @deprecated Will be made private in v5.0 */ + this.sectionExpandedClass = 'govuk-accordion__section--expanded' - /** @deprecated Will be made private in v5.0 */ - this.sectionButtonClass = 'govuk-accordion__section-button' + /** @deprecated Will be made private in v5.0 */ + this.sectionButtonClass = 'govuk-accordion__section-button' - /** @deprecated Will be made private in v5.0 */ - this.sectionHeaderClass = 'govuk-accordion__section-header' + /** @deprecated Will be made private in v5.0 */ + this.sectionHeaderClass = 'govuk-accordion__section-header' - /** @deprecated Will be made private in v5.0 */ - this.sectionHeadingClass = 'govuk-accordion__section-heading' + /** @deprecated Will be made private in v5.0 */ + this.sectionHeadingClass = 'govuk-accordion__section-heading' - /** @deprecated Will be made private in v5.0 */ - this.sectionHeadingDividerClass = 'govuk-accordion__section-heading-divider' + /** @deprecated Will be made private in v5.0 */ + this.sectionHeadingDividerClass = 'govuk-accordion__section-heading-divider' - /** @deprecated Will be made private in v5.0 */ - this.sectionHeadingTextClass = 'govuk-accordion__section-heading-text' + /** @deprecated Will be made private in v5.0 */ + this.sectionHeadingTextClass = 'govuk-accordion__section-heading-text' - /** @deprecated Will be made private in v5.0 */ - this.sectionHeadingTextFocusClass = 'govuk-accordion__section-heading-text-focus' + /** @deprecated Will be made private in v5.0 */ + this.sectionHeadingTextFocusClass = 'govuk-accordion__section-heading-text-focus' - /** @deprecated Will be made private in v5.0 */ - this.sectionShowHideToggleClass = 'govuk-accordion__section-toggle' + /** @deprecated Will be made private in v5.0 */ + this.sectionShowHideToggleClass = 'govuk-accordion__section-toggle' - /** @deprecated Will be made private in v5.0 */ - this.sectionShowHideToggleFocusClass = 'govuk-accordion__section-toggle-focus' + /** @deprecated Will be made private in v5.0 */ + this.sectionShowHideToggleFocusClass = 'govuk-accordion__section-toggle-focus' - /** @deprecated Will be made private in v5.0 */ - this.sectionShowHideTextClass = 'govuk-accordion__section-toggle-text' + /** @deprecated Will be made private in v5.0 */ + this.sectionShowHideTextClass = 'govuk-accordion__section-toggle-text' - /** @deprecated Will be made private in v5.0 */ - this.upChevronIconClass = 'govuk-accordion-nav__chevron' + /** @deprecated Will be made private in v5.0 */ + this.upChevronIconClass = 'govuk-accordion-nav__chevron' - /** @deprecated Will be made private in v5.0 */ - this.downChevronIconClass = 'govuk-accordion-nav__chevron--down' + /** @deprecated Will be made private in v5.0 */ + this.downChevronIconClass = 'govuk-accordion-nav__chevron--down' - /** @deprecated Will be made private in v5.0 */ - this.sectionSummaryClass = 'govuk-accordion__section-summary' + /** @deprecated Will be made private in v5.0 */ + this.sectionSummaryClass = 'govuk-accordion__section-summary' - /** @deprecated Will be made private in v5.0 */ - this.sectionSummaryFocusClass = 'govuk-accordion__section-summary-focus' + /** @deprecated Will be made private in v5.0 */ + this.sectionSummaryFocusClass = 'govuk-accordion__section-summary-focus' - /** @deprecated Will be made private in v5.0 */ - this.sectionContentClass = 'govuk-accordion__section-content' + /** @deprecated Will be made private in v5.0 */ + this.sectionContentClass = 'govuk-accordion__section-content' - const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`) - if (!$sections.length) { - return this - } + const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`) + if (!$sections.length) { + return this + } - /** @deprecated Will be made private in v5.0 */ - this.$sections = $sections + /** @deprecated Will be made private in v5.0 */ + this.$sections = $sections - /** @deprecated Will be made private in v5.0 */ - this.browserSupportsSessionStorage = helper.checkForSessionStorage() + /** @deprecated Will be made private in v5.0 */ + this.browserSupportsSessionStorage = helper.checkForSessionStorage() - /** @deprecated Will be made private in v5.0 */ - this.$showAllButton = null + /** @deprecated Will be made private in v5.0 */ + this.$showAllButton = null - /** @deprecated Will be made private in v5.0 */ - this.$showAllIcon = null + /** @deprecated Will be made private in v5.0 */ + this.$showAllIcon = null - /** @deprecated Will be made private in v5.0 */ - this.$showAllText = null -} + /** @deprecated Will be made private in v5.0 */ + this.$showAllText = null + } -/** - * Initialise component - */ -Accordion.prototype.init = function () { - // Check that required elements are present - if (!this.$module || !this.$sections) { - return + /** + * Initialise component + */ + init () { + // Check that required elements are present + if (!this.$module || !this.$sections) { + return + } + + this.initControls() + this.initSectionHeaders() + + // See if "Show all sections" button text should be updated + const areAllSectionsOpen = this.checkIfAllSectionsOpen() + this.updateShowAllButton(areAllSectionsOpen) + } + + /** + * Initialise controls and set attributes + * + * @deprecated Will be made private in v5.0 + */ + initControls () { + // Create "Show all" button and set attributes + this.$showAllButton = document.createElement('button') + this.$showAllButton.setAttribute('type', 'button') + this.$showAllButton.setAttribute('class', this.showAllClass) + this.$showAllButton.setAttribute('aria-expanded', 'false') + + // Create icon, add to element + this.$showAllIcon = document.createElement('span') + this.$showAllIcon.classList.add(this.upChevronIconClass) + this.$showAllButton.appendChild(this.$showAllIcon) + + // Create control wrapper and add controls to it + const $accordionControls = document.createElement('div') + $accordionControls.setAttribute('class', this.controlsClass) + $accordionControls.appendChild(this.$showAllButton) + this.$module.insertBefore($accordionControls, this.$module.firstChild) + + // Build additional wrapper for Show all toggle text and place after icon + this.$showAllText = document.createElement('span') + this.$showAllText.classList.add(this.showAllTextClass) + this.$showAllButton.appendChild(this.$showAllText) + + // Handle click events on the show/hide all button + this.$showAllButton.addEventListener('click', () => this.onShowOrHideAllToggle()) + + // Handle 'beforematch' events, if the user agent supports them + if ('onbeforematch' in document) { + document.addEventListener('beforematch', (event) => this.onBeforeMatch(event)) + } } - this.initControls() - this.initSectionHeaders() + /** + * Initialise section headers + * + * @deprecated Will be made private in v5.0 + */ + initSectionHeaders () { + const $sections = this.$sections + + // Loop through sections + $sections.forEach(($section, i) => { + const $header = $section.querySelector(`.${this.sectionHeaderClass}`) + if (!$header) { + return + } - // See if "Show all sections" button text should be updated - const areAllSectionsOpen = this.checkIfAllSectionsOpen() - this.updateShowAllButton(areAllSectionsOpen) -} + // Set header attributes + this.constructHeaderMarkup($header, i) + this.setExpanded(this.isExpanded($section), $section) -/** - * Initialise controls and set attributes - * - * @deprecated Will be made private in v5.0 - */ -Accordion.prototype.initControls = function () { - // Create "Show all" button and set attributes - this.$showAllButton = document.createElement('button') - this.$showAllButton.setAttribute('type', 'button') - this.$showAllButton.setAttribute('class', this.showAllClass) - this.$showAllButton.setAttribute('aria-expanded', 'false') - - // Create icon, add to element - this.$showAllIcon = document.createElement('span') - this.$showAllIcon.classList.add(this.upChevronIconClass) - this.$showAllButton.appendChild(this.$showAllIcon) - - // Create control wrapper and add controls to it - const $accordionControls = document.createElement('div') - $accordionControls.setAttribute('class', this.controlsClass) - $accordionControls.appendChild(this.$showAllButton) - this.$module.insertBefore($accordionControls, this.$module.firstChild) - - // Build additional wrapper for Show all toggle text and place after icon - this.$showAllText = document.createElement('span') - this.$showAllText.classList.add(this.showAllTextClass) - this.$showAllButton.appendChild(this.$showAllText) - - // Handle click events on the show/hide all button - this.$showAllButton.addEventListener('click', () => this.onShowOrHideAllToggle()) - - // Handle 'beforematch' events, if the user agent supports them - if ('onbeforematch' in document) { - document.addEventListener('beforematch', (event) => this.onBeforeMatch(event)) + // Handle events + $header.addEventListener('click', () => this.onSectionToggle($section)) + + // See if there is any state stored in sessionStorage and set the sections to + // open or closed. + this.setInitialState($section) + }) } -} -/** - * Initialise section headers - * - * @deprecated Will be made private in v5.0 - */ -Accordion.prototype.initSectionHeaders = function () { - const $sections = this.$sections + /** + * Construct section header + * + * @deprecated Will be made private in v5.0 + * @param {Element} $header - Section header + * @param {number} index - Section index + */ + constructHeaderMarkup ($header, index) { + const $span = $header.querySelector(`.${this.sectionButtonClass}`) + const $heading = $header.querySelector(`.${this.sectionHeadingClass}`) + const $summary = $header.querySelector(`.${this.sectionSummaryClass}`) - // Loop through sections - $sections.forEach(($section, i) => { - const $header = $section.querySelector(`.${this.sectionHeaderClass}`) - if (!$header) { + if (!$span || !$heading) { return } - // Set header attributes - this.constructHeaderMarkup($header, i) - this.setExpanded(this.isExpanded($section), $section) + // Create a button element that will replace the '.govuk-accordion__section-button' span + const $button = document.createElement('button') + $button.setAttribute('type', 'button') + $button.setAttribute('aria-controls', `${this.$module.id}-content-${index + 1}`) + + // Copy all attributes (https://developer.mozilla.org/en-US/docs/Web/API/Element/attributes) from $span to $button + for (let i = 0; i < $span.attributes.length; i++) { + const attr = $span.attributes.item(i) + // Add all attributes but not ID as this is being added to + // the section heading ($headingText) + if (attr.nodeName !== 'id') { + $button.setAttribute(attr.nodeName, attr.nodeValue) + } + } - // Handle events - $header.addEventListener('click', () => this.onSectionToggle($section)) + // Create container for heading text so it can be styled + const $headingText = document.createElement('span') + $headingText.classList.add(this.sectionHeadingTextClass) + // Copy the span ID to the heading text to allow it to be referenced by `aria-labelledby` on the + // hidden content area without "Show this section" + $headingText.id = $span.id + + // Create an inner heading text container to limit the width of the focus state + const $headingTextFocus = document.createElement('span') + $headingTextFocus.classList.add(this.sectionHeadingTextFocusClass) + $headingText.appendChild($headingTextFocus) + // span could contain HTML elements (see https://www.w3.org/TR/2011/WD-html5-20110525/content-models.html#phrasing-content) + $headingTextFocus.innerHTML = $span.innerHTML + + // Create container for show / hide icons and text. + const $showHideToggle = document.createElement('span') + $showHideToggle.classList.add(this.sectionShowHideToggleClass) + // Tell Google not to index the 'show' text as part of the heading + // For the snippet to work with JavaScript, it must be added before adding the page element to the + // page's DOM. See https://developers.google.com/search/docs/advanced/robots/robots_meta_tag#data-nosnippet-attr + $showHideToggle.setAttribute('data-nosnippet', '') + // Create an inner container to limit the width of the focus state + const $showHideToggleFocus = document.createElement('span') + $showHideToggleFocus.classList.add(this.sectionShowHideToggleFocusClass) + $showHideToggle.appendChild($showHideToggleFocus) + // Create wrapper for the show / hide text. Append text after the show/hide icon + const $showHideText = document.createElement('span') + const $showHideIcon = document.createElement('span') + $showHideIcon.classList.add(this.upChevronIconClass) + $showHideToggleFocus.appendChild($showHideIcon) + $showHideText.classList.add(this.sectionShowHideTextClass) + $showHideToggleFocus.appendChild($showHideText) + + // Append elements to the button: + // 1. Heading text + // 2. Punctuation + // 3. (Optional: Summary line followed by punctuation) + // 4. Show / hide toggle + $button.appendChild($headingText) + $button.appendChild(this.getButtonPunctuationEl()) - // See if there is any state stored in sessionStorage and set the sections to - // open or closed. - this.setInitialState($section) - }) -} + // If summary content exists add to DOM in correct order + if ($summary) { + // Create a new `span` element and copy the summary line content from the original `div` to the + // new `span` + // This is because the summary line text is now inside a button element, which can only contain + // phrasing content + const $summarySpan = document.createElement('span') + // Create an inner summary container to limit the width of the summary focus state + const $summarySpanFocus = document.createElement('span') + $summarySpanFocus.classList.add(this.sectionSummaryFocusClass) + $summarySpan.appendChild($summarySpanFocus) + + // Get original attributes, and pass them to the replacement + for (let j = 0, l = $summary.attributes.length; j < l; ++j) { + const nodeName = $summary.attributes.item(j).nodeName + const nodeValue = $summary.attributes.item(j).nodeValue + $summarySpan.setAttribute(nodeName, nodeValue) + } -/** - * Construct section header - * - * @deprecated Will be made private in v5.0 - * @param {Element} $header - Section header - * @param {number} index - Section index - */ -Accordion.prototype.constructHeaderMarkup = function ($header, index) { - const $span = $header.querySelector(`.${this.sectionButtonClass}`) - const $heading = $header.querySelector(`.${this.sectionHeadingClass}`) - const $summary = $header.querySelector(`.${this.sectionSummaryClass}`) + // Copy original contents of summary to the new summary span + $summarySpanFocus.innerHTML = $summary.innerHTML - if (!$span || !$heading) { - return - } + // Replace the original summary `div` with the new summary `span` + $summary.parentNode.replaceChild($summarySpan, $summary) - // Create a button element that will replace the '.govuk-accordion__section-button' span - const $button = document.createElement('button') - $button.setAttribute('type', 'button') - $button.setAttribute('aria-controls', `${this.$module.id}-content-${index + 1}`) - - // Copy all attributes (https://developer.mozilla.org/en-US/docs/Web/API/Element/attributes) from $span to $button - for (let i = 0; i < $span.attributes.length; i++) { - const attr = $span.attributes.item(i) - // Add all attributes but not ID as this is being added to - // the section heading ($headingText) - if (attr.nodeName !== 'id') { - $button.setAttribute(attr.nodeName, attr.nodeValue) + $button.appendChild($summarySpan) + $button.appendChild(this.getButtonPunctuationEl()) } + + $button.appendChild($showHideToggle) + + $heading.removeChild($span) + $heading.appendChild($button) } - // Create container for heading text so it can be styled - const $headingText = document.createElement('span') - $headingText.classList.add(this.sectionHeadingTextClass) - // Copy the span ID to the heading text to allow it to be referenced by `aria-labelledby` on the - // hidden content area without "Show this section" - $headingText.id = $span.id - - // Create an inner heading text container to limit the width of the focus state - const $headingTextFocus = document.createElement('span') - $headingTextFocus.classList.add(this.sectionHeadingTextFocusClass) - $headingText.appendChild($headingTextFocus) - // span could contain HTML elements (see https://www.w3.org/TR/2011/WD-html5-20110525/content-models.html#phrasing-content) - $headingTextFocus.innerHTML = $span.innerHTML - - // Create container for show / hide icons and text. - const $showHideToggle = document.createElement('span') - $showHideToggle.classList.add(this.sectionShowHideToggleClass) - // Tell Google not to index the 'show' text as part of the heading - // For the snippet to work with JavaScript, it must be added before adding the page element to the - // page's DOM. See https://developers.google.com/search/docs/advanced/robots/robots_meta_tag#data-nosnippet-attr - $showHideToggle.setAttribute('data-nosnippet', '') - // Create an inner container to limit the width of the focus state - const $showHideToggleFocus = document.createElement('span') - $showHideToggleFocus.classList.add(this.sectionShowHideToggleFocusClass) - $showHideToggle.appendChild($showHideToggleFocus) - // Create wrapper for the show / hide text. Append text after the show/hide icon - const $showHideText = document.createElement('span') - const $showHideIcon = document.createElement('span') - $showHideIcon.classList.add(this.upChevronIconClass) - $showHideToggleFocus.appendChild($showHideIcon) - $showHideText.classList.add(this.sectionShowHideTextClass) - $showHideToggleFocus.appendChild($showHideText) - - // Append elements to the button: - // 1. Heading text - // 2. Punctuation - // 3. (Optional: Summary line followed by punctuation) - // 4. Show / hide toggle - $button.appendChild($headingText) - $button.appendChild(this.getButtonPunctuationEl()) - - // If summary content exists add to DOM in correct order - if ($summary) { - // Create a new `span` element and copy the summary line content from the original `div` to the - // new `span` - // This is because the summary line text is now inside a button element, which can only contain - // phrasing content - const $summarySpan = document.createElement('span') - // Create an inner summary container to limit the width of the summary focus state - const $summarySpanFocus = document.createElement('span') - $summarySpanFocus.classList.add(this.sectionSummaryFocusClass) - $summarySpan.appendChild($summarySpanFocus) - - // Get original attributes, and pass them to the replacement - for (let j = 0, l = $summary.attributes.length; j < l; ++j) { - const nodeName = $summary.attributes.item(j).nodeName - const nodeValue = $summary.attributes.item(j).nodeValue - $summarySpan.setAttribute(nodeName, nodeValue) + /** + * When a section is opened by the user agent via the 'beforematch' event + * + * @deprecated Will be made private in v5.0 + * @param {Event} event - Generic event + */ + onBeforeMatch (event) { + const $fragment = event.target + + // Handle elements with `.closest()` support only + if (!($fragment instanceof Element)) { + return } - // Copy original contents of summary to the new summary span - $summarySpanFocus.innerHTML = $summary.innerHTML + // Handle when fragment is inside section + const $section = $fragment.closest(`.${this.sectionClass}`) + if ($section) { + this.setExpanded(true, $section) + } + } - // Replace the original summary `div` with the new summary `span` - $summary.parentNode.replaceChild($summarySpan, $summary) + /** + * When section toggled, set and store state + * + * @deprecated Will be made private in v5.0 + * @param {Element} $section - Section element + */ + onSectionToggle ($section) { + const expanded = this.isExpanded($section) + this.setExpanded(!expanded, $section) - $button.appendChild($summarySpan) - $button.appendChild(this.getButtonPunctuationEl()) + // Store the state in sessionStorage when a change is triggered + this.storeState($section) } - $button.appendChild($showHideToggle) - - $heading.removeChild($span) - $heading.appendChild($button) -} + /** + * When Open/Close All toggled, set and store state + * + * @deprecated Will be made private in v5.0 + */ + onShowOrHideAllToggle () { + const $sections = this.$sections -/** - * When a section is opened by the user agent via the 'beforematch' event - * - * @deprecated Will be made private in v5.0 - * @param {Event} event - Generic event - */ -Accordion.prototype.onBeforeMatch = function (event) { - const $fragment = event.target + const nowExpanded = !this.checkIfAllSectionsOpen() - // Handle elements with `.closest()` support only - if (!($fragment instanceof Element)) { - return - } + // Loop through sections + $sections.forEach(($section) => { + this.setExpanded(nowExpanded, $section) + // Store the state in sessionStorage when a change is triggered + this.storeState($section) + }) - // Handle when fragment is inside section - const $section = $fragment.closest(`.${this.sectionClass}`) - if ($section) { - this.setExpanded(true, $section) + this.updateShowAllButton(nowExpanded) } -} -/** - * When section toggled, set and store state - * - * @deprecated Will be made private in v5.0 - * @param {Element} $section - Section element - */ -Accordion.prototype.onSectionToggle = function ($section) { - const expanded = this.isExpanded($section) - this.setExpanded(!expanded, $section) - - // Store the state in sessionStorage when a change is triggered - this.storeState($section) -} + /** + * Set section attributes when opened/closed + * + * @deprecated Will be made private in v5.0 + * @param {boolean} expanded - Section expanded + * @param {Element} $section - Section element + */ + setExpanded (expanded, $section) { + const $showHideIcon = $section.querySelector(`.${this.upChevronIconClass}`) + const $showHideText = $section.querySelector(`.${this.sectionShowHideTextClass}`) + const $button = $section.querySelector(`.${this.sectionButtonClass}`) + const $content = $section.querySelector(`.${this.sectionContentClass}`) -/** - * When Open/Close All toggled, set and store state - * - * @deprecated Will be made private in v5.0 - */ -Accordion.prototype.onShowOrHideAllToggle = function () { - const $sections = this.$sections + if (!$showHideIcon || + !($showHideText instanceof HTMLElement) || + !$button || + !$content) { + return + } - const nowExpanded = !this.checkIfAllSectionsOpen() + const newButtonText = expanded + ? this.i18n.t('hideSection') + : this.i18n.t('showSection') - // Loop through sections - $sections.forEach(($section) => { - this.setExpanded(nowExpanded, $section) - // Store the state in sessionStorage when a change is triggered - this.storeState($section) - }) + $showHideText.innerText = newButtonText + $button.setAttribute('aria-expanded', `${expanded}`) - this.updateShowAllButton(nowExpanded) -} + // Update aria-label combining + const ariaLabelParts = [] -/** - * Set section attributes when opened/closed - * - * @deprecated Will be made private in v5.0 - * @param {boolean} expanded - Section expanded - * @param {Element} $section - Section element - */ -Accordion.prototype.setExpanded = function (expanded, $section) { - const $showHideIcon = $section.querySelector(`.${this.upChevronIconClass}`) - const $showHideText = $section.querySelector(`.${this.sectionShowHideTextClass}`) - const $button = $section.querySelector(`.${this.sectionButtonClass}`) - const $content = $section.querySelector(`.${this.sectionContentClass}`) - - if (!$showHideIcon || - !($showHideText instanceof HTMLElement) || - !$button || - !$content) { - return - } + const $headingText = $section.querySelector(`.${this.sectionHeadingTextClass}`) + if ($headingText instanceof HTMLElement) { + ariaLabelParts.push($headingText.innerText.trim()) + } - const newButtonText = expanded - ? this.i18n.t('hideSection') - : this.i18n.t('showSection') + const $summary = $section.querySelector(`.${this.sectionSummaryClass}`) + if ($summary instanceof HTMLElement) { + ariaLabelParts.push($summary.innerText.trim()) + } - $showHideText.innerText = newButtonText - $button.setAttribute('aria-expanded', `${expanded}`) + const ariaLabelMessage = expanded + ? this.i18n.t('hideSectionAriaLabel') + : this.i18n.t('showSectionAriaLabel') + ariaLabelParts.push(ariaLabelMessage) + + /* + * Join with a comma to add pause for assistive technology. + * Example: [heading]Section A ,[pause] Show this section. + * https://accessibility.blog.gov.uk/2017/12/18/what-working-on-gov-uk-navigation-taught-us-about-accessibility/ + */ + $button.setAttribute('aria-label', ariaLabelParts.join(' , ')) + + // Swap icon, change class + if (expanded) { + $content.removeAttribute('hidden') + $section.classList.add(this.sectionExpandedClass) + $showHideIcon.classList.remove(this.downChevronIconClass) + } else { + $content.setAttribute('hidden', 'until-found') + $section.classList.remove(this.sectionExpandedClass) + $showHideIcon.classList.add(this.downChevronIconClass) + } - // Update aria-label combining - const ariaLabelParts = [] + // See if "Show all sections" button text should be updated + const areAllSectionsOpen = this.checkIfAllSectionsOpen() + this.updateShowAllButton(areAllSectionsOpen) + } - const $headingText = $section.querySelector(`.${this.sectionHeadingTextClass}`) - if ($headingText instanceof HTMLElement) { - ariaLabelParts.push($headingText.innerText.trim()) + /** + * Get state of section + * + * @deprecated Will be made private in v5.0 + * @param {Element} $section - Section element + * @returns {boolean} True if expanded + */ + isExpanded ($section) { + return $section.classList.contains(this.sectionExpandedClass) } - const $summary = $section.querySelector(`.${this.sectionSummaryClass}`) - if ($summary instanceof HTMLElement) { - ariaLabelParts.push($summary.innerText.trim()) + /** + * Check if all sections are open + * + * @deprecated Will be made private in v5.0 + * @returns {boolean} True if all sections are open + */ + checkIfAllSectionsOpen () { + // Get a count of all the Accordion sections + const sectionsCount = this.$sections.length + // Get a count of all Accordion sections that are expanded + const expandedSectionCount = this.$module.querySelectorAll(`.${this.sectionExpandedClass}`).length + const areAllSectionsOpen = sectionsCount === expandedSectionCount + + return areAllSectionsOpen } - const ariaLabelMessage = expanded - ? this.i18n.t('hideSectionAriaLabel') - : this.i18n.t('showSectionAriaLabel') - ariaLabelParts.push(ariaLabelMessage) + /** + * Update "Show all sections" button + * + * @deprecated Will be made private in v5.0 + * @param {boolean} expanded - Section expanded + */ + updateShowAllButton (expanded) { + const newButtonText = expanded + ? this.i18n.t('hideAllSections') + : this.i18n.t('showAllSections') + + this.$showAllButton.setAttribute('aria-expanded', expanded.toString()) + this.$showAllText.innerText = newButtonText + + // Swap icon, toggle class + if (expanded) { + this.$showAllIcon.classList.remove(this.downChevronIconClass) + } else { + this.$showAllIcon.classList.add(this.downChevronIconClass) + } + } - /* - * Join with a comma to add pause for assistive technology. - * Example: [heading]Section A ,[pause] Show this section. - * https://accessibility.blog.gov.uk/2017/12/18/what-working-on-gov-uk-navigation-taught-us-about-accessibility/ + /** + * Set the state of the accordions in sessionStorage + * + * @deprecated Will be made private in v5.0 + * @param {Element} $section - Section element */ - $button.setAttribute('aria-label', ariaLabelParts.join(' , ')) - - // Swap icon, change class - if (expanded) { - $content.removeAttribute('hidden') - $section.classList.add(this.sectionExpandedClass) - $showHideIcon.classList.remove(this.downChevronIconClass) - } else { - $content.setAttribute('hidden', 'until-found') - $section.classList.remove(this.sectionExpandedClass) - $showHideIcon.classList.add(this.downChevronIconClass) + storeState ($section) { + if (this.browserSupportsSessionStorage && this.config.rememberExpanded) { + // We need a unique way of identifying each content in the Accordion. Since + // an `#id` should be unique and an `id` is required for `aria-` attributes + // `id` can be safely used. + const $button = $section.querySelector(`.${this.sectionButtonClass}`) + + if ($button) { + const contentId = $button.getAttribute('aria-controls') + const contentState = $button.getAttribute('aria-expanded') + + // Only set the state when both `contentId` and `contentState` are taken from the DOM. + if (contentId && contentState) { + window.sessionStorage.setItem(contentId, contentState) + } + } + } } - // See if "Show all sections" button text should be updated - const areAllSectionsOpen = this.checkIfAllSectionsOpen() - this.updateShowAllButton(areAllSectionsOpen) -} + /** + * Read the state of the accordions from sessionStorage + * + * @deprecated Will be made private in v5.0 + * @param {Element} $section - Section element + */ + setInitialState ($section) { + if (this.browserSupportsSessionStorage && this.config.rememberExpanded) { + const $button = $section.querySelector(`.${this.sectionButtonClass}`) -/** - * Get state of section - * - * @deprecated Will be made private in v5.0 - * @param {Element} $section - Section element - * @returns {boolean} True if expanded - */ -Accordion.prototype.isExpanded = function ($section) { - return $section.classList.contains(this.sectionExpandedClass) -} + if ($button) { + const contentId = $button.getAttribute('aria-controls') + const contentState = contentId ? window.sessionStorage.getItem(contentId) : null -/** - * Check if all sections are open - * - * @deprecated Will be made private in v5.0 - * @returns {boolean} True if all sections are open - */ -Accordion.prototype.checkIfAllSectionsOpen = function () { - // Get a count of all the Accordion sections - const sectionsCount = this.$sections.length - // Get a count of all Accordion sections that are expanded - const expandedSectionCount = this.$module.querySelectorAll(`.${this.sectionExpandedClass}`).length - const areAllSectionsOpen = sectionsCount === expandedSectionCount - - return areAllSectionsOpen -} + if (contentState !== null) { + this.setExpanded(contentState === 'true', $section) + } + } + } + } -/** - * Update "Show all sections" button - * - * @deprecated Will be made private in v5.0 - * @param {boolean} expanded - Section expanded - */ -Accordion.prototype.updateShowAllButton = function (expanded) { - const newButtonText = expanded - ? this.i18n.t('hideAllSections') - : this.i18n.t('showAllSections') - - this.$showAllButton.setAttribute('aria-expanded', expanded.toString()) - this.$showAllText.innerText = newButtonText - - // Swap icon, toggle class - if (expanded) { - this.$showAllIcon.classList.remove(this.downChevronIconClass) - } else { - this.$showAllIcon.classList.add(this.downChevronIconClass) + /** + * Create an element to improve semantics of the section button with punctuation + * + * Adding punctuation to the button can also improve its general semantics by dividing its contents + * into thematic chunks. + * See https://github.com/alphagov/govuk-frontend/issues/2327#issuecomment-922957442 + * + * @deprecated Will be made private in v5.0 + * @returns {Element} DOM element + */ + getButtonPunctuationEl () { + const $punctuationEl = document.createElement('span') + $punctuationEl.classList.add('govuk-visually-hidden', this.sectionHeadingDividerClass) + $punctuationEl.innerHTML = ', ' + return $punctuationEl } } @@ -521,71 +586,6 @@ const helper = { } } -/** - * Set the state of the accordions in sessionStorage - * - * @deprecated Will be made private in v5.0 - * @param {Element} $section - Section element - */ -Accordion.prototype.storeState = function ($section) { - if (this.browserSupportsSessionStorage && this.config.rememberExpanded) { - // We need a unique way of identifying each content in the Accordion. Since - // an `#id` should be unique and an `id` is required for `aria-` attributes - // `id` can be safely used. - const $button = $section.querySelector(`.${this.sectionButtonClass}`) - - if ($button) { - const contentId = $button.getAttribute('aria-controls') - const contentState = $button.getAttribute('aria-expanded') - - // Only set the state when both `contentId` and `contentState` are taken from the DOM. - if (contentId && contentState) { - window.sessionStorage.setItem(contentId, contentState) - } - } - } -} - -/** - * Read the state of the accordions from sessionStorage - * - * @deprecated Will be made private in v5.0 - * @param {Element} $section - Section element - */ -Accordion.prototype.setInitialState = function ($section) { - if (this.browserSupportsSessionStorage && this.config.rememberExpanded) { - const $button = $section.querySelector(`.${this.sectionButtonClass}`) - - if ($button) { - const contentId = $button.getAttribute('aria-controls') - const contentState = contentId ? window.sessionStorage.getItem(contentId) : null - - if (contentState !== null) { - this.setExpanded(contentState === 'true', $section) - } - } - } -} - -/** - * Create an element to improve semantics of the section button with punctuation - * - * Adding punctuation to the button can also improve its general semantics by dividing its contents - * into thematic chunks. - * See https://github.com/alphagov/govuk-frontend/issues/2327#issuecomment-922957442 - * - * @deprecated Will be made private in v5.0 - * @returns {Element} DOM element - */ -Accordion.prototype.getButtonPunctuationEl = function () { - const $punctuationEl = document.createElement('span') - $punctuationEl.classList.add('govuk-visually-hidden', this.sectionHeadingDividerClass) - $punctuationEl.innerHTML = ', ' - return $punctuationEl -} - -export default Accordion - /** * Accordion config * diff --git a/packages/govuk-frontend/src/govuk/components/button/button.mjs b/packages/govuk-frontend/src/govuk/components/button/button.mjs index ca1f476ebc..7921e6c4d8 100644 --- a/packages/govuk-frontend/src/govuk/components/button/button.mjs +++ b/packages/govuk-frontend/src/govuk/components/button/button.mjs @@ -6,107 +6,108 @@ const DEBOUNCE_TIMEOUT_IN_SECONDS = 1 /** * JavaScript enhancements for the Button component - * - * @class - * @param {Element} $module - HTML element to use for button - * @param {ButtonConfig} [config] - Button config */ -function Button ($module, config) { - if (!($module instanceof HTMLElement)) { - return this - } - - /** @deprecated Will be made private in v5.0 */ - this.$module = $module - - /** @deprecated Will be made private in v5.0 */ - this.debounceFormSubmitTimer = null - - /** @type {ButtonConfig} */ - const defaultConfig = { - preventDoubleClick: false - } - +export default class Button { /** - * @deprecated Will be made private in v5.0 - * @type {ButtonConfig} + * + * @param {Element} $module - HTML element to use for button + * @param {ButtonConfig} [config] - Button config */ - this.config = mergeConfigs( - defaultConfig, - config || {}, - normaliseDataset($module.dataset) - ) -} + constructor ($module, config) { + if (!($module instanceof HTMLElement)) { + return this + } -/** - * Initialise component - */ -Button.prototype.init = function () { - // Check that required elements are present - if (!this.$module) { - return - } - - this.$module.addEventListener('keydown', (event) => this.handleKeyDown(event)) - this.$module.addEventListener('click', (event) => this.debounce(event)) -} + /** @deprecated Will be made private in v5.0 */ + this.$module = $module -/** - * Trigger a click event when the space key is pressed - * - * Some screen readers tell users they can activate things with the 'button' - * role, so we need to match the functionality of native HTML buttons - * - * See https://github.com/alphagov/govuk_elements/pull/272#issuecomment-233028270 - * - * @deprecated Will be made private in v5.0 - * @param {KeyboardEvent} event - Keydown event - */ -Button.prototype.handleKeyDown = function (event) { - const $target = event.target + /** @deprecated Will be made private in v5.0 */ + this.debounceFormSubmitTimer = null - // Handle space bar only - if (event.keyCode !== KEY_SPACE) { - return + /** @type {ButtonConfig} */ + const defaultConfig = { + preventDoubleClick: false + } + + /** + * @deprecated Will be made private in v5.0 + * @type {ButtonConfig} + */ + this.config = mergeConfigs( + defaultConfig, + config || {}, + normaliseDataset($module.dataset) + ) } - // Handle elements with [role="button"] only - if ($target instanceof HTMLElement && $target.getAttribute('role') === 'button') { - event.preventDefault() // prevent the page from scrolling - $target.click() + /** + * Initialise component + */ + init () { + // Check that required elements are present + if (!this.$module) { + return + } + + this.$module.addEventListener('keydown', (event) => this.handleKeyDown(event)) + this.$module.addEventListener('click', (event) => this.debounce(event)) } -} -/** - * Debounce double-clicks - * - * If the click quickly succeeds a previous click then nothing will happen. This - * stops people accidentally causing multiple form submissions by double - * clicking buttons. - * - * @deprecated Will be made private in v5.0 - * @param {MouseEvent} event - Mouse click event - * @returns {undefined | false} Returns undefined, or false when debounced - */ -Button.prototype.debounce = function (event) { - // Check the button that was clicked has preventDoubleClick enabled - if (!this.config.preventDoubleClick) { - return + /** + * Trigger a click event when the space key is pressed + * + * Some screen readers tell users they can activate things with the 'button' + * role, so we need to match the functionality of native HTML buttons + * + * See https://github.com/alphagov/govuk_elements/pull/272#issuecomment-233028270 + * + * @deprecated Will be made private in v5.0 + * @param {KeyboardEvent} event - Keydown event + */ + handleKeyDown (event) { + const $target = event.target + + // Handle space bar only + if (event.keyCode !== KEY_SPACE) { + return + } + + // Handle elements with [role="button"] only + if ($target instanceof HTMLElement && $target.getAttribute('role') === 'button') { + event.preventDefault() // prevent the page from scrolling + $target.click() + } } - // If the timer is still running, prevent the click from submitting the form - if (this.debounceFormSubmitTimer) { - event.preventDefault() - return false + /** + * Debounce double-clicks + * + * If the click quickly succeeds a previous click then nothing will happen. This + * stops people accidentally causing multiple form submissions by double + * clicking buttons. + * + * @deprecated Will be made private in v5.0 + * @param {MouseEvent} event - Mouse click event + * @returns {undefined | false} Returns undefined, or false when debounced + */ + debounce (event) { + // Check the button that was clicked has preventDoubleClick enabled + if (!this.config.preventDoubleClick) { + return + } + + // If the timer is still running, prevent the click from submitting the form + if (this.debounceFormSubmitTimer) { + event.preventDefault() + return false + } + + this.debounceFormSubmitTimer = setTimeout(() => { + this.debounceFormSubmitTimer = null + }, DEBOUNCE_TIMEOUT_IN_SECONDS * 1000) } - - this.debounceFormSubmitTimer = setTimeout(() => { - this.debounceFormSubmitTimer = null - }, DEBOUNCE_TIMEOUT_IN_SECONDS * 1000) } -export default Button - /** * Button config * diff --git a/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs b/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs index 1fbacb2052..5c51dab0cd 100644 --- a/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs +++ b/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs @@ -46,385 +46,385 @@ const CHARACTER_COUNT_TRANSLATIONS = { * * You can configure the message to only appear after a certain percentage * of the available characters/words has been entered. - * - * @class - * @param {Element} $module - HTML element to use for character count - * @param {CharacterCountConfig} [config] - Character count config */ -function CharacterCount ($module, config) { - if (!($module instanceof HTMLElement)) { - return this - } +export default class CharacterCount { + /** + * @param {Element} $module - HTML element to use for character count + * @param {CharacterCountConfig} [config] - Character count config + */ + constructor ($module, config) { + if (!($module instanceof HTMLElement)) { + return this + } - const $textarea = $module.querySelector('.govuk-js-character-count') - if ( - !( - $textarea instanceof HTMLTextAreaElement || - $textarea instanceof HTMLInputElement - ) - ) { - return this - } + const $textarea = $module.querySelector('.govuk-js-character-count') + if ( + !( + $textarea instanceof HTMLTextAreaElement || + $textarea instanceof HTMLInputElement + ) + ) { + return this + } - /** @type {CharacterCountConfig} */ - const defaultConfig = { - threshold: 0, - i18n: CHARACTER_COUNT_TRANSLATIONS - } + /** @type {CharacterCountConfig} */ + const defaultConfig = { + threshold: 0, + i18n: CHARACTER_COUNT_TRANSLATIONS + } - // Read config set using dataset ('data-' values) - const datasetConfig = normaliseDataset($module.dataset) - - // To ensure data-attributes take complete precedence, even if they change the - // type of count, we need to reset the `maxlength` and `maxwords` from the - // JavaScript config. - // - // We can't mutate `config`, though, as it may be shared across multiple - // components inside `initAll`. - /** @type {CharacterCountConfig} */ - let configOverrides = {} - if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) { - configOverrides = { - maxlength: undefined, - maxwords: undefined + // Read config set using dataset ('data-' values) + const datasetConfig = normaliseDataset($module.dataset) + + // To ensure data-attributes take complete precedence, even if they change the + // type of count, we need to reset the `maxlength` and `maxwords` from the + // JavaScript config. + // + // We can't mutate `config`, though, as it may be shared across multiple + // components inside `initAll`. + /** @type {CharacterCountConfig} */ + let configOverrides = {} + if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) { + configOverrides = { + maxlength: undefined, + maxwords: undefined + } } - } - /** - * @deprecated Will be made private in v5.0 - * @type {CharacterCountConfig} - */ - this.config = mergeConfigs( - defaultConfig, - config || {}, - configOverrides, - datasetConfig - ) - - /** @deprecated Will be made private in v5.0 */ - this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), { - // Read the fallback if necessary rather than have it set in the defaults - locale: closestAttributeValue($module, 'lang') - }) - - /** @deprecated Will be made private in v5.0 */ - this.maxLength = Infinity - // Determine the limit attribute (characters or words) - if ('maxwords' in this.config && this.config.maxwords) { - this.maxLength = this.config.maxwords - } else if ('maxlength' in this.config && this.config.maxlength) { - this.maxLength = this.config.maxlength - } else { - return - } + /** + * @deprecated Will be made private in v5.0 + * @type {CharacterCountConfig} + */ + this.config = mergeConfigs( + defaultConfig, + config || {}, + configOverrides, + datasetConfig + ) - /** @deprecated Will be made private in v5.0 */ - this.$module = $module + /** @deprecated Will be made private in v5.0 */ + this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), { + // Read the fallback if necessary rather than have it set in the defaults + locale: closestAttributeValue($module, 'lang') + }) + + /** @deprecated Will be made private in v5.0 */ + this.maxLength = Infinity + // Determine the limit attribute (characters or words) + if ('maxwords' in this.config && this.config.maxwords) { + this.maxLength = this.config.maxwords + } else if ('maxlength' in this.config && this.config.maxlength) { + this.maxLength = this.config.maxlength + } else { + return + } - /** @deprecated Will be made private in v5.0 */ - this.$textarea = $textarea + /** @deprecated Will be made private in v5.0 */ + this.$module = $module - /** @deprecated Will be made private in v5.0 */ - this.$visibleCountMessage = null + /** @deprecated Will be made private in v5.0 */ + this.$textarea = $textarea - /** @deprecated Will be made private in v5.0 */ - this.$screenReaderCountMessage = null + /** @deprecated Will be made private in v5.0 */ + this.$visibleCountMessage = null - /** @deprecated Will be made private in v5.0 */ - this.lastInputTimestamp = null + /** @deprecated Will be made private in v5.0 */ + this.$screenReaderCountMessage = null - /** @deprecated Will be made private in v5.0 */ - this.lastInputValue = '' + /** @deprecated Will be made private in v5.0 */ + this.lastInputTimestamp = null - /** @deprecated Will be made private in v5.0 */ - this.valueChecker = null -} + /** @deprecated Will be made private in v5.0 */ + this.lastInputValue = '' -/** - * Initialise component - */ -CharacterCount.prototype.init = function () { - // Check that required elements are present - if (!this.$module || !this.$textarea) { - return + /** @deprecated Will be made private in v5.0 */ + this.valueChecker = null } - const $textarea = this.$textarea - const $textareaDescription = document.getElementById(`${$textarea.id}-info`) - if (!$textareaDescription) { - return - } - - // Inject a description for the textarea if none is present already - // for when the component was rendered with no maxlength, maxwords - // nor custom textareaDescriptionText - if ($textareaDescription.innerText.match(/^\s*$/)) { - $textareaDescription.innerText = this.i18n.t('textareaDescription', { count: this.maxLength }) - } + /** + * Initialise component + */ + init () { + // Check that required elements are present + if (!this.$module || !this.$textarea) { + return + } - // Move the textarea description to be immediately after the textarea - // Kept for backwards compatibility - $textarea.insertAdjacentElement('afterend', $textareaDescription) - - // 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 = 'govuk-character-count__sr-status govuk-visually-hidden' - $screenReaderCountMessage.setAttribute('aria-live', 'polite') - this.$screenReaderCountMessage = $screenReaderCountMessage - $textareaDescription.insertAdjacentElement('afterend', $screenReaderCountMessage) - - // Create our live-updating counter element, copying the classes from the - // textarea description for backwards compatibility as these may have been - // configured - const $visibleCountMessage = document.createElement('div') - $visibleCountMessage.className = $textareaDescription.className - $visibleCountMessage.classList.add('govuk-character-count__status') - $visibleCountMessage.setAttribute('aria-hidden', 'true') - this.$visibleCountMessage = $visibleCountMessage - $textareaDescription.insertAdjacentElement('afterend', $visibleCountMessage) - - // Hide the textarea description - $textareaDescription.classList.add('govuk-visually-hidden') - - // Remove hard limit if set - $textarea.removeAttribute('maxlength') - - this.bindChangeEvents() - - // 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. - window.addEventListener('pageshow', () => this.updateCountMessage()) - - // Although we've set up handlers to sync state on the pageshow event, init - // could be called after those events have fired, for example if they are - // added to the page dynamically, so update now too. - this.updateCountMessage() -} + const $textarea = this.$textarea + const $textareaDescription = document.getElementById(`${$textarea.id}-info`) + if (!$textareaDescription) { + return + } -/** - * Bind change events - * - * Set up event listeners on the $textarea so that the count messages update - * when the user types. - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.bindChangeEvents = function () { - const $textarea = this.$textarea - $textarea.addEventListener('keyup', () => this.handleKeyUp()) + // Inject a description for the textarea if none is present already + // for when the component was rendered with no maxlength, maxwords + // nor custom textareaDescriptionText + if ($textareaDescription.innerText.match(/^\s*$/)) { + $textareaDescription.innerText = this.i18n.t('textareaDescription', { count: this.maxLength }) + } - // Bind focus/blur events to start/stop polling - $textarea.addEventListener('focus', () => this.handleFocus()) - $textarea.addEventListener('blur', () => this.handleBlur()) -} + // Move the textarea description to be immediately after the textarea + // Kept for backwards compatibility + $textarea.insertAdjacentElement('afterend', $textareaDescription) + + // 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 = 'govuk-character-count__sr-status govuk-visually-hidden' + $screenReaderCountMessage.setAttribute('aria-live', 'polite') + this.$screenReaderCountMessage = $screenReaderCountMessage + $textareaDescription.insertAdjacentElement('afterend', $screenReaderCountMessage) + + // Create our live-updating counter element, copying the classes from the + // textarea description for backwards compatibility as these may have been + // configured + const $visibleCountMessage = document.createElement('div') + $visibleCountMessage.className = $textareaDescription.className + $visibleCountMessage.classList.add('govuk-character-count__status') + $visibleCountMessage.setAttribute('aria-hidden', 'true') + this.$visibleCountMessage = $visibleCountMessage + $textareaDescription.insertAdjacentElement('afterend', $visibleCountMessage) + + // Hide the textarea description + $textareaDescription.classList.add('govuk-visually-hidden') + + // Remove hard limit if set + $textarea.removeAttribute('maxlength') + + this.bindChangeEvents() + + // 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. + window.addEventListener('pageshow', () => this.updateCountMessage()) + + // Although we've set up handlers to sync state on the pageshow event, init + // could be called after those events have fired, for example if they are + // added to the page dynamically, so update now too. + this.updateCountMessage() + } -/** - * Handle key up event - * - * Update the visible character counter and keep track of when the last update - * happened for each keypress - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.handleKeyUp = function () { - this.updateVisibleCountMessage() - this.lastInputTimestamp = Date.now() -} + /** + * Bind change events + * + * Set up event listeners on the $textarea so that the count messages update + * when the user types. + * + * @deprecated Will be made private in v5.0 + */ + bindChangeEvents () { + const $textarea = this.$textarea + $textarea.addEventListener('keyup', () => this.handleKeyUp()) -/** - * Handle focus event - * - * 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. - * - * Once the keyup event hasn't been detected for at least 1000 ms (1s), check if - * the textarea value has changed and update the count message if it has. - * - * This is so that the update triggered by the manual comparison doesn't - * conflict with debounced KeyboardEvent updates. - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.handleFocus = function () { - this.valueChecker = setInterval(() => { - if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) { - this.updateIfValueChanged() - } - }, 1000) -} + // Bind focus/blur events to start/stop polling + $textarea.addEventListener('focus', () => this.handleFocus()) + $textarea.addEventListener('blur', () => this.handleBlur()) + } -/** - * Handle blur event - * - * Stop checking the textarea value once the textarea no longer has focus - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.handleBlur = function () { - // Cancel value checking on blur - clearInterval(this.valueChecker) -} + /** + * Handle key up event + * + * Update the visible character counter and keep track of when the last update + * happened for each keypress + * + * @deprecated Will be made private in v5.0 + */ + handleKeyUp () { + this.updateVisibleCountMessage() + this.lastInputTimestamp = Date.now() + } -/** - * Update count message if textarea value has changed - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.updateIfValueChanged = function () { - if (this.$textarea.value !== this.lastInputValue) { - this.lastInputValue = this.$textarea.value - this.updateCountMessage() + /** + * Handle focus event + * + * 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. + * + * Once the keyup event hasn't been detected for at least 1000 ms (1s), check if + * the textarea value has changed and update the count message if it has. + * + * This is so that the update triggered by the manual comparison doesn't + * conflict with debounced KeyboardEvent updates. + * + * @deprecated Will be made private in v5.0 + */ + handleFocus () { + this.valueChecker = setInterval(() => { + if (!this.lastInputTimestamp || (Date.now() - 500) >= this.lastInputTimestamp) { + this.updateIfValueChanged() + } + }, 1000) } -} -/** - * Update count message - * - * Helper function to update both the visible and screen reader-specific - * counters simultaneously (e.g. on init) - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.updateCountMessage = function () { - this.updateVisibleCountMessage() - this.updateScreenReaderCountMessage() -} + /** + * Handle blur event + * + * Stop checking the textarea value once the textarea no longer has focus + * + * @deprecated Will be made private in v5.0 + */ + handleBlur () { + // Cancel value checking on blur + clearInterval(this.valueChecker) + } -/** - * Update visible count message - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.updateVisibleCountMessage = function () { - const $textarea = this.$textarea - const $visibleCountMessage = this.$visibleCountMessage - 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('govuk-character-count__message--disabled') - } else { - $visibleCountMessage.classList.add('govuk-character-count__message--disabled') + /** + * Update count message if textarea value has changed + * + * @deprecated Will be made private in v5.0 + */ + updateIfValueChanged () { + if (this.$textarea.value !== this.lastInputValue) { + this.lastInputValue = this.$textarea.value + this.updateCountMessage() + } } - // Update styles - if (remainingNumber < 0) { - $textarea.classList.add('govuk-textarea--error') - $visibleCountMessage.classList.remove('govuk-hint') - $visibleCountMessage.classList.add('govuk-error-message') - } else { - $textarea.classList.remove('govuk-textarea--error') - $visibleCountMessage.classList.remove('govuk-error-message') - $visibleCountMessage.classList.add('govuk-hint') + /** + * Update count message + * + * Helper function to update both the visible and screen reader-specific + * counters simultaneously (e.g. on init) + * + * @deprecated Will be made private in v5.0 + */ + updateCountMessage () { + this.updateVisibleCountMessage() + this.updateScreenReaderCountMessage() } - // Update message - $visibleCountMessage.innerText = this.getCountMessage() -} + /** + * Update visible count message + * + * @deprecated Will be made private in v5.0 + */ + updateVisibleCountMessage () { + const $textarea = this.$textarea + const $visibleCountMessage = this.$visibleCountMessage + 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('govuk-character-count__message--disabled') + } else { + $visibleCountMessage.classList.add('govuk-character-count__message--disabled') + } -/** - * Update screen reader count message - * - * @deprecated Will be made private in v5.0 - */ -CharacterCount.prototype.updateScreenReaderCountMessage = function () { - const $screenReaderCountMessage = this.$screenReaderCountMessage - - // 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 styles + if (remainingNumber < 0) { + $textarea.classList.add('govuk-textarea--error') + $visibleCountMessage.classList.remove('govuk-hint') + $visibleCountMessage.classList.add('govuk-error-message') + } else { + $textarea.classList.remove('govuk-textarea--error') + $visibleCountMessage.classList.remove('govuk-error-message') + $visibleCountMessage.classList.add('govuk-hint') + } + + // Update message + $visibleCountMessage.innerText = this.getCountMessage() } - // Update message - $screenReaderCountMessage.innerText = this.getCountMessage() -} + /** + * Update screen reader count message + * + * @deprecated Will be made private in v5.0 + */ + updateScreenReaderCountMessage () { + const $screenReaderCountMessage = this.$screenReaderCountMessage + + // 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') + } -/** - * Count the number of characters (or words, if `config.maxwords` is set) - * in the given text - * - * @deprecated Will be made private in v5.0 - * @param {string} text - The text to count the characters of - * @returns {number} the number of characters (or words) in the text - */ -CharacterCount.prototype.count = function (text) { - if ('maxwords' in this.config && this.config.maxwords) { - const tokens = text.match(/\S+/g) || [] // Matches consecutive non-whitespace chars - return tokens.length - } else { - return text.length + // Update message + $screenReaderCountMessage.innerText = this.getCountMessage() } -} -/** - * Get count message - * - * @deprecated Will be made private in v5.0 - * @returns {string} Status message - */ -CharacterCount.prototype.getCountMessage = function () { - const remainingNumber = this.maxLength - this.count(this.$textarea.value) + /** + * Count the number of characters (or words, if `config.maxwords` is set) + * in the given text + * + * @deprecated Will be made private in v5.0 + * @param {string} text - The text to count the characters of + * @returns {number} the number of characters (or words) in the text + */ + count (text) { + if ('maxwords' in this.config && this.config.maxwords) { + const tokens = text.match(/\S+/g) || [] // Matches consecutive non-whitespace chars + return tokens.length + } else { + return text.length + } + } - const countType = 'maxwords' in this.config && this.config.maxwords ? 'words' : 'characters' - return this.formatCountMessage(remainingNumber, countType) -} + /** + * Get count message + * + * @deprecated Will be made private in v5.0 + * @returns {string} Status message + */ + getCountMessage () { + const remainingNumber = this.maxLength - this.count(this.$textarea.value) -/** - * Formats the message shown to users according to what's counted - * and how many remain - * - * @deprecated Will be made private in v5.0 - * @param {number} remainingNumber - The number of words/characaters remaining - * @param {string} countType - "words" or "characters" - * @returns {string} Status message - */ -CharacterCount.prototype.formatCountMessage = function (remainingNumber, countType) { - if (remainingNumber === 0) { - return this.i18n.t(`${countType}AtLimit`) + const countType = 'maxwords' in this.config && this.config.maxwords ? 'words' : 'characters' + return this.formatCountMessage(remainingNumber, countType) } - const translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit' + /** + * Formats the message shown to users according to what's counted + * and how many remain + * + * @deprecated Will be made private in v5.0 + * @param {number} remainingNumber - The number of words/characaters remaining + * @param {string} countType - "words" or "characters" + * @returns {string} Status message + */ + formatCountMessage (remainingNumber, countType) { + if (remainingNumber === 0) { + return this.i18n.t(`${countType}AtLimit`) + } - return this.i18n.t(`${countType}${translationKeySuffix}`, { count: Math.abs(remainingNumber) }) -} + const translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit' -/** - * Check if count is over threshold - * - * 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. - * - * @deprecated Will be made private in v5.0 - * @returns {boolean} true if the current count is over the config.threshold - * (or no threshold is set) - */ -CharacterCount.prototype.isOverThreshold = function () { - // No threshold means we're always above threshold so save some computation - if (!this.config.threshold) { - return true + return this.i18n.t(`${countType}${translationKeySuffix}`, { count: Math.abs(remainingNumber) }) } - const $textarea = this.$textarea + /** + * Check if count is over threshold + * + * 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. + * + * @deprecated Will be made private in v5.0 + * @returns {boolean} true if the current count is over the config.threshold + * (or no threshold is set) + */ + isOverThreshold () { + // No threshold means we're always above threshold so save some computation + if (!this.config.threshold) { + return true + } + + const $textarea = this.$textarea - // Determine the remaining number of characters/words - const currentLength = this.count($textarea.value) - const maxLength = this.maxLength + // Determine the remaining number of characters/words + const currentLength = this.count($textarea.value) + const maxLength = this.maxLength - const thresholdValue = maxLength * this.config.threshold / 100 + const thresholdValue = maxLength * this.config.threshold / 100 - return (thresholdValue <= currentLength) + return (thresholdValue <= currentLength) + } } -export default CharacterCount - /** * Character count config * diff --git a/packages/govuk-frontend/src/govuk/components/checkboxes/checkboxes.mjs b/packages/govuk-frontend/src/govuk/components/checkboxes/checkboxes.mjs index 22a8010196..edd3fb02a3 100644 --- a/packages/govuk-frontend/src/govuk/components/checkboxes/checkboxes.mjs +++ b/packages/govuk-frontend/src/govuk/components/checkboxes/checkboxes.mjs @@ -1,196 +1,196 @@ /** * Checkboxes component - * - * @class - * @param {Element} $module - HTML element to use for checkboxes */ -function Checkboxes ($module) { - if (!($module instanceof HTMLElement)) { - return this - } - - /** @satisfies {NodeListOf} */ - const $inputs = $module.querySelectorAll('input[type="checkbox"]') - if (!$inputs.length) { - return this - } +export default class Checkboxes { + /** + * @param {Element} $module - HTML element to use for checkboxes + */ + constructor ($module) { + if (!($module instanceof HTMLElement)) { + return this + } - /** @deprecated Will be made private in v5.0 */ - this.$module = $module + /** @satisfies {NodeListOf} */ + const $inputs = $module.querySelectorAll('input[type="checkbox"]') + if (!$inputs.length) { + return this + } - /** @deprecated Will be made private in v5.0 */ - this.$inputs = $inputs -} + /** @deprecated Will be made private in v5.0 */ + this.$module = $module -/** - * Initialise component - * - * Checkboxes can be associated with a 'conditionally revealed' content block – - * for example, a checkbox for 'Phone' could reveal an additional form field for - * the user to enter their phone number. - * - * These associations are made using a `data-aria-controls` attribute, which is - * promoted to an aria-controls attribute during initialisation. - * - * We also need to restore the state of any conditional reveals on the page (for - * example if the user has navigated back), and set up event handlers to keep - * the reveal in sync with the checkbox state. - */ -Checkboxes.prototype.init = function () { - // Check that required elements are present - if (!this.$module || !this.$inputs) { - return + /** @deprecated Will be made private in v5.0 */ + this.$inputs = $inputs } - const $module = this.$module - const $inputs = this.$inputs - - $inputs.forEach(($input) => { - const targetId = $input.getAttribute('data-aria-controls') - - // Skip checkboxes without data-aria-controls attributes, or where the - // target element does not exist. - if (!targetId || !document.getElementById(targetId)) { + /** + * Initialise component + * + * Checkboxes can be associated with a 'conditionally revealed' content block – + * for example, a checkbox for 'Phone' could reveal an additional form field for + * the user to enter their phone number. + * + * These associations are made using a `data-aria-controls` attribute, which is + * promoted to an aria-controls attribute during initialisation. + * + * We also need to restore the state of any conditional reveals on the page (for + * example if the user has navigated back), and set up event handlers to keep + * the reveal in sync with the checkbox state. + */ + init () { + // Check that required elements are present + if (!this.$module || !this.$inputs) { return } - // Promote the data-aria-controls attribute to a aria-controls attribute - // so that the relationship is exposed in the AOM - $input.setAttribute('aria-controls', targetId) - $input.removeAttribute('data-aria-controls') - }) + const $module = this.$module + const $inputs = this.$inputs - // 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. - window.addEventListener('pageshow', () => this.syncAllConditionalReveals()) + $inputs.forEach(($input) => { + const targetId = $input.getAttribute('data-aria-controls') - // Although we've set up handlers to sync state on the pageshow event, init - // could be called after those events have fired, for example if they are - // added to the page dynamically, so sync now too. - this.syncAllConditionalReveals() + // Skip checkboxes without data-aria-controls attributes, or where the + // target element does not exist. + if (!targetId || !document.getElementById(targetId)) { + return + } - // Handle events - $module.addEventListener('click', (event) => this.handleClick(event)) -} + // Promote the data-aria-controls attribute to a aria-controls attribute + // so that the relationship is exposed in the AOM + $input.setAttribute('aria-controls', targetId) + $input.removeAttribute('data-aria-controls') + }) -/** - * Sync the conditional reveal states for all checkboxes in this $module. - * - * @deprecated Will be made private in v5.0 - */ -Checkboxes.prototype.syncAllConditionalReveals = function () { - this.$inputs.forEach(($input) => this.syncConditionalRevealWithInputState($input)) -} - -/** - * Sync conditional reveal with the input state - * - * Synchronise the visibility of the conditional reveal, and its accessible - * state, with the input's checked state. - * - * @deprecated Will be made private in v5.0 - * @param {HTMLInputElement} $input - Checkbox input - */ -Checkboxes.prototype.syncConditionalRevealWithInputState = function ($input) { - const targetId = $input.getAttribute('aria-controls') - if (!targetId) { - return - } + // 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. + window.addEventListener('pageshow', () => this.syncAllConditionalReveals()) - const $target = document.getElementById(targetId) - if ($target && $target.classList.contains('govuk-checkboxes__conditional')) { - const inputIsChecked = $input.checked + // Although we've set up handlers to sync state on the pageshow event, init + // could be called after those events have fired, for example if they are + // added to the page dynamically, so sync now too. + this.syncAllConditionalReveals() - $input.setAttribute('aria-expanded', inputIsChecked.toString()) - $target.classList.toggle('govuk-checkboxes__conditional--hidden', !inputIsChecked) + // Handle events + $module.addEventListener('click', (event) => this.handleClick(event)) } -} -/** - * Uncheck other checkboxes - * - * Find any other checkbox inputs with the same name value, and uncheck them. - * This is useful for when a “None of these" checkbox is checked. - * - * @deprecated Will be made private in v5.0 - * @param {HTMLInputElement} $input - Checkbox input - */ -Checkboxes.prototype.unCheckAllInputsExcept = function ($input) { - /** @satisfies {NodeListOf} */ - const allInputsWithSameName = document.querySelectorAll( - `input[type="checkbox"][name="${$input.name}"]` - ) - - allInputsWithSameName.forEach(($inputWithSameName) => { - const hasSameFormOwner = ($input.form === $inputWithSameName.form) - if (hasSameFormOwner && $inputWithSameName !== $input) { - $inputWithSameName.checked = false - this.syncConditionalRevealWithInputState($inputWithSameName) - } - }) -} + /** + * Sync the conditional reveal states for all checkboxes in this $module. + * + * @deprecated Will be made private in v5.0 + */ + syncAllConditionalReveals () { + this.$inputs.forEach(($input) => this.syncConditionalRevealWithInputState($input)) + } -/** - * Uncheck exclusive checkboxes - * - * Find any checkbox inputs with the same name 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. - * - * @deprecated Will be made private in v5.0 - * @param {HTMLInputElement} $input - Checkbox input - */ -Checkboxes.prototype.unCheckExclusiveInputs = function ($input) { - /** @satisfies {NodeListOf} */ - const allInputsWithSameNameAndExclusiveBehaviour = document.querySelectorAll( - `input[data-behaviour="exclusive"][type="checkbox"][name="${$input.name}"]` - ) - - allInputsWithSameNameAndExclusiveBehaviour.forEach(($exclusiveInput) => { - const hasSameFormOwner = ($input.form === $exclusiveInput.form) - if (hasSameFormOwner) { - $exclusiveInput.checked = false - this.syncConditionalRevealWithInputState($exclusiveInput) + /** + * Sync conditional reveal with the input state + * + * Synchronise the visibility of the conditional reveal, and its accessible + * state, with the input's checked state. + * + * @deprecated Will be made private in v5.0 + * @param {HTMLInputElement} $input - Checkbox input + */ + syncConditionalRevealWithInputState ($input) { + const targetId = $input.getAttribute('aria-controls') + if (!targetId) { + return } - }) -} -/** - * Click event handler - * - * Handle a click within the $module – if the click occurred on a checkbox, sync - * the state of any associated conditional reveal with the checkbox state. - * - * @deprecated Will be made private in v5.0 - * @param {MouseEvent} event - Click event - */ -Checkboxes.prototype.handleClick = function (event) { - const $clickedInput = event.target + const $target = document.getElementById(targetId) + if ($target && $target.classList.contains('govuk-checkboxes__conditional')) { + const inputIsChecked = $input.checked - // Ignore clicks on things that aren't checkbox inputs - if (!($clickedInput instanceof HTMLInputElement) || $clickedInput.type !== 'checkbox') { - return + $input.setAttribute('aria-expanded', inputIsChecked.toString()) + $target.classList.toggle('govuk-checkboxes__conditional--hidden', !inputIsChecked) + } } - // If the checkbox conditionally-reveals some content, sync the state - const hasAriaControls = $clickedInput.getAttribute('aria-controls') - if (hasAriaControls) { - this.syncConditionalRevealWithInputState($clickedInput) + /** + * Uncheck other checkboxes + * + * Find any other checkbox inputs with the same name value, and uncheck them. + * This is useful for when a “None of these" checkbox is checked. + * + * @deprecated Will be made private in v5.0 + * @param {HTMLInputElement} $input - Checkbox input + */ + unCheckAllInputsExcept ($input) { + /** @satisfies {NodeListOf} */ + const allInputsWithSameName = document.querySelectorAll( + `input[type="checkbox"][name="${$input.name}"]` + ) + + allInputsWithSameName.forEach(($inputWithSameName) => { + const hasSameFormOwner = ($input.form === $inputWithSameName.form) + if (hasSameFormOwner && $inputWithSameName !== $input) { + $inputWithSameName.checked = false + this.syncConditionalRevealWithInputState($inputWithSameName) + } + }) } - // No further behaviour needed for unchecking - if (!$clickedInput.checked) { - return + /** + * Uncheck exclusive checkboxes + * + * Find any checkbox inputs with the same name 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. + * + * @deprecated Will be made private in v5.0 + * @param {HTMLInputElement} $input - Checkbox input + */ + unCheckExclusiveInputs ($input) { + /** @satisfies {NodeListOf} */ + const allInputsWithSameNameAndExclusiveBehaviour = document.querySelectorAll( + `input[data-behaviour="exclusive"][type="checkbox"][name="${$input.name}"]` + ) + + allInputsWithSameNameAndExclusiveBehaviour.forEach(($exclusiveInput) => { + const hasSameFormOwner = ($input.form === $exclusiveInput.form) + if (hasSameFormOwner) { + $exclusiveInput.checked = false + this.syncConditionalRevealWithInputState($exclusiveInput) + } + }) } - // Handle 'exclusive' checkbox behaviour (ie "None of these") - const hasBehaviourExclusive = ($clickedInput.getAttribute('data-behaviour') === 'exclusive') - if (hasBehaviourExclusive) { - this.unCheckAllInputsExcept($clickedInput) - } else { - this.unCheckExclusiveInputs($clickedInput) + /** + * Click event handler + * + * Handle a click within the $module – if the click occurred on a checkbox, sync + * the state of any associated conditional reveal with the checkbox state. + * + * @deprecated Will be made private in v5.0 + * @param {MouseEvent} event - Click event + */ + handleClick (event) { + const $clickedInput = event.target + + // Ignore clicks on things that aren't checkbox inputs + if (!($clickedInput instanceof HTMLInputElement) || $clickedInput.type !== 'checkbox') { + return + } + + // If the checkbox conditionally-reveals some content, sync the state + const hasAriaControls = $clickedInput.getAttribute('aria-controls') + if (hasAriaControls) { + this.syncConditionalRevealWithInputState($clickedInput) + } + + // No further behaviour needed for unchecking + if (!$clickedInput.checked) { + return + } + + // Handle 'exclusive' checkbox behaviour (ie "None of these") + const hasBehaviourExclusive = ($clickedInput.getAttribute('data-behaviour') === 'exclusive') + if (hasBehaviourExclusive) { + this.unCheckAllInputsExcept($clickedInput) + } else { + this.unCheckExclusiveInputs($clickedInput) + } } } - -export default Checkboxes diff --git a/packages/govuk-frontend/src/govuk/components/details/details.mjs b/packages/govuk-frontend/src/govuk/components/details/details.mjs index 9432ea8686..c3474f33d2 100644 --- a/packages/govuk-frontend/src/govuk/components/details/details.mjs +++ b/packages/govuk-frontend/src/govuk/components/details/details.mjs @@ -11,151 +11,152 @@ const KEY_SPACE = 32 /** * Details component - * - * @class - * @param {Element} $module - HTML element to use for details */ -function Details ($module) { - if (!($module instanceof HTMLElement)) { - return this - } - - /** @deprecated Will be made private in v5.0 */ - this.$module = $module +export default class Details { + /** + * + * @param {Element} $module - HTML element to use for details + */ + constructor ($module) { + if (!($module instanceof HTMLElement)) { + return this + } - /** @deprecated Will be made private in v5.0 */ - this.$summary = null + /** @deprecated Will be made private in v5.0 */ + this.$module = $module - /** @deprecated Will be made private in v5.0 */ - this.$content = null -} + /** @deprecated Will be made private in v5.0 */ + this.$summary = null -/** - * Initialise component - */ -Details.prototype.init = function () { - // Check that required elements are present - if (!this.$module) { - return + /** @deprecated Will be made private in v5.0 */ + this.$content = null } - // If there is native details support, we want to avoid running code to polyfill native behaviour. - const hasNativeDetails = 'HTMLDetailsElement' in window && - this.$module instanceof HTMLDetailsElement + /** + * Initialise component + */ + init () { + // Check that required elements are present + if (!this.$module) { + return + } + + // If there is native details support, we want to avoid running code to polyfill native behaviour. + const hasNativeDetails = 'HTMLDetailsElement' in window && + this.$module instanceof HTMLDetailsElement - if (!hasNativeDetails) { - this.polyfillDetails() + if (!hasNativeDetails) { + this.polyfillDetails() + } } -} -/** - * Polyfill component in older browsers - * - * @deprecated Will be made private in v5.0 - */ -Details.prototype.polyfillDetails = function () { - const $module = this.$module + /** + * Polyfill component in older browsers + * + * @deprecated Will be made private in v5.0 + */ + polyfillDetails () { + const $module = this.$module + + // Save shortcuts to the inner summary and content elements + const $summary = this.$summary = $module.getElementsByTagName('summary').item(0) + const $content = this.$content = $module.getElementsByTagName('div').item(0) + + // If
doesn't have a and a
representing the content + // it means the required HTML structure is not met so the script will stop + if (!$summary || !$content) { + return + } - // Save shortcuts to the inner summary and content elements - const $summary = this.$summary = $module.getElementsByTagName('summary').item(0) - const $content = this.$content = $module.getElementsByTagName('div').item(0) + // If the content doesn't have an ID, assign it one now + // which we'll need for the summary's aria-controls assignment + if (!$content.id) { + $content.id = `details-content-${generateUniqueID()}` + } - // If
doesn't have a and a
representing the content - // it means the required HTML structure is not met so the script will stop - if (!$summary || !$content) { - return - } + // Add ARIA role="group" to details + $module.setAttribute('role', 'group') - // If the content doesn't have an ID, assign it one now - // which we'll need for the summary's aria-controls assignment - if (!$content.id) { - $content.id = `details-content-${generateUniqueID()}` - } + // Add role=button to summary + $summary.setAttribute('role', 'button') - // Add ARIA role="group" to details - $module.setAttribute('role', 'group') + // Add aria-controls + $summary.setAttribute('aria-controls', $content.id) - // Add role=button to summary - $summary.setAttribute('role', 'button') + // Set tabIndex so the summary is keyboard accessible for non-native elements + // + // We have to use the camelcase `tabIndex` property as there is a bug in IE6/IE7 when we set the correct attribute lowercase: + // See http://web.archive.org/web/20170120194036/http://www.saliences.com/browserBugs/tabIndex.html for more information. + $summary.tabIndex = 0 - // Add aria-controls - $summary.setAttribute('aria-controls', $content.id) - - // Set tabIndex so the summary is keyboard accessible for non-native elements - // - // We have to use the camelcase `tabIndex` property as there is a bug in IE6/IE7 when we set the correct attribute lowercase: - // See http://web.archive.org/web/20170120194036/http://www.saliences.com/browserBugs/tabIndex.html for more information. - $summary.tabIndex = 0 + // Detect initial open state + if (this.$module.hasAttribute('open')) { + $summary.setAttribute('aria-expanded', 'true') + } else { + $summary.setAttribute('aria-expanded', 'false') + $content.style.display = 'none' + } - // Detect initial open state - if (this.$module.hasAttribute('open')) { - $summary.setAttribute('aria-expanded', 'true') - } else { - $summary.setAttribute('aria-expanded', 'false') - $content.style.display = 'none' + // Bind an event to handle summary elements + this.polyfillHandleInputs(() => this.polyfillSetAttributes()) } - // Bind an event to handle summary elements - this.polyfillHandleInputs(() => this.polyfillSetAttributes()) -} + /** + * Define a statechange function that updates aria-expanded and style.display + * + * @deprecated Will be made private in v5.0 + * @returns {boolean} Returns true + */ + polyfillSetAttributes () { + if (this.$module.hasAttribute('open')) { + this.$module.removeAttribute('open') + this.$summary.setAttribute('aria-expanded', 'false') + this.$content.style.display = 'none' + } else { + this.$module.setAttribute('open', 'open') + this.$summary.setAttribute('aria-expanded', 'true') + this.$content.style.display = '' + } -/** - * Define a statechange function that updates aria-expanded and style.display - * - * @deprecated Will be made private in v5.0 - * @returns {boolean} Returns true - */ -Details.prototype.polyfillSetAttributes = function () { - if (this.$module.hasAttribute('open')) { - this.$module.removeAttribute('open') - this.$summary.setAttribute('aria-expanded', 'false') - this.$content.style.display = 'none' - } else { - this.$module.setAttribute('open', 'open') - this.$summary.setAttribute('aria-expanded', 'true') - this.$content.style.display = '' + return true } - return true -} - -/** - * Handle cross-modal click events - * - * @deprecated Will be made private in v5.0 - * @param {(event: UIEvent) => void} callback - function - */ -Details.prototype.polyfillHandleInputs = function (callback) { - this.$summary.addEventListener('keypress', (event) => { - const $target = event.target - // When the key gets pressed - check if it is enter or space - if (event.keyCode === KEY_ENTER || event.keyCode === KEY_SPACE) { - if ($target instanceof HTMLElement && $target.nodeName.toLowerCase() === 'summary') { - // Prevent space from scrolling the page - // and enter from submitting a form - event.preventDefault() - // Click to let the click event do all the necessary action - if ($target.click) { - $target.click() - } else { - // except Safari 5.1 and under don't support .click() here - callback(event) + /** + * Handle cross-modal click events + * + * @deprecated Will be made private in v5.0 + * @param {(event: UIEvent) => void} callback - function + */ + polyfillHandleInputs (callback) { + this.$summary.addEventListener('keypress', (event) => { + const $target = event.target + // When the key gets pressed - check if it is enter or space + if (event.keyCode === KEY_ENTER || event.keyCode === KEY_SPACE) { + if ($target instanceof HTMLElement && $target.nodeName.toLowerCase() === 'summary') { + // Prevent space from scrolling the page + // and enter from submitting a form + event.preventDefault() + // Click to let the click event do all the necessary action + if ($target.click) { + $target.click() + } else { + // except Safari 5.1 and under don't support .click() here + callback(event) + } } } - } - }) - - // Prevent keyup to prevent clicking twice in Firefox when using space key - this.$summary.addEventListener('keyup', (event) => { - const $target = event.target - if (event.keyCode === KEY_SPACE) { - if ($target instanceof HTMLElement && $target.nodeName.toLowerCase() === 'summary') { - event.preventDefault() + }) + + // Prevent keyup to prevent clicking twice in Firefox when using space key + this.$summary.addEventListener('keyup', (event) => { + const $target = event.target + if (event.keyCode === KEY_SPACE) { + if ($target instanceof HTMLElement && $target.nodeName.toLowerCase() === 'summary') { + event.preventDefault() + } } - } - }) + }) - this.$summary.addEventListener('click', callback) + this.$summary.addEventListener('click', callback) + } } - -export default Details diff --git a/packages/govuk-frontend/src/govuk/components/error-summary/error-summary.mjs b/packages/govuk-frontend/src/govuk/components/error-summary/error-summary.mjs index ef30ed8b5b..5b46cd28ef 100644 --- a/packages/govuk-frontend/src/govuk/components/error-summary/error-summary.mjs +++ b/packages/govuk-frontend/src/govuk/components/error-summary/error-summary.mjs @@ -5,220 +5,221 @@ import { normaliseDataset } from '../../common/normalise-dataset.mjs' * JavaScript enhancements for the ErrorSummary * * Takes focus on initialisation for accessible announcement, unless disabled in configuration. - * - * @class - * @param {Element} $module - HTML element to use for error summary - * @param {ErrorSummaryConfig} [config] - Error summary config */ -function ErrorSummary ($module, config) { - // Some consuming code may not be passing a module, - // for example if they initialise the component - // on their own by directly passing the result - // of `document.querySelector`. - // To avoid breaking further JavaScript initialisation - // we need to safeguard against this so things keep - // working the same now we read the elements data attributes - if (!($module instanceof HTMLElement)) { - // Little safety in case code gets ported as-is - // into and ES2015 class constructor, where the return value matters - return this - } +export default class ErrorSummary { + /** + * + * @param {Element} $module - HTML element to use for error summary + * @param {ErrorSummaryConfig} [config] - Error summary config + */ + constructor ($module, config) { + // Some consuming code may not be passing a module, + // for example if they initialise the component + // on their own by directly passing the result + // of `document.querySelector`. + // To avoid breaking further JavaScript initialisation + // we need to safeguard against this so things keep + // working the same now we read the elements data attributes + if (!($module instanceof HTMLElement)) { + // Little safety in case code gets ported as-is + // into and ES2015 class constructor, where the return value matters + return this + } - /** @deprecated Will be made private in v5.0 */ - this.$module = $module + /** @deprecated Will be made private in v5.0 */ + this.$module = $module - /** @type {ErrorSummaryConfig} */ - const defaultConfig = { - disableAutoFocus: false + /** @type {ErrorSummaryConfig} */ + const defaultConfig = { + disableAutoFocus: false + } + + /** + * @deprecated Will be made private in v5.0 + * @type {ErrorSummaryConfig} + */ + this.config = mergeConfigs( + defaultConfig, + config || {}, + normaliseDataset($module.dataset) + ) } /** - * @deprecated Will be made private in v5.0 - * @type {ErrorSummaryConfig} + * Initialise component */ - this.config = mergeConfigs( - defaultConfig, - config || {}, - normaliseDataset($module.dataset) - ) -} - -/** - * Initialise component - */ -ErrorSummary.prototype.init = function () { - // Check that required elements are present - if (!this.$module) { - return - } - - const $module = this.$module - - this.setFocus() - $module.addEventListener('click', (event) => this.handleClick(event)) -} + init () { + // Check that required elements are present + if (!this.$module) { + return + } -/** - * Focus the error summary - * - * @deprecated Will be made private in v5.0 - */ -ErrorSummary.prototype.setFocus = function () { - const $module = this.$module + const $module = this.$module - if (this.config.disableAutoFocus) { - return + this.setFocus() + $module.addEventListener('click', (event) => this.handleClick(event)) } - // Set tabindex to -1 to make the element programmatically focusable, but - // remove it on blur as the error summary doesn't need to be focused again. - $module.setAttribute('tabindex', '-1') + /** + * Focus the error summary + * + * @deprecated Will be made private in v5.0 + */ + setFocus () { + const $module = this.$module - $module.addEventListener('blur', () => { - $module.removeAttribute('tabindex') - }) + if (this.config.disableAutoFocus) { + return + } - $module.focus() -} + // Set tabindex to -1 to make the element programmatically focusable, but + // remove it on blur as the error summary doesn't need to be focused again. + $module.setAttribute('tabindex', '-1') -/** - * Click event handler - * - * @deprecated Will be made private in v5.0 - * @param {MouseEvent} event - Click event - */ -ErrorSummary.prototype.handleClick = function (event) { - const $target = event.target - if (this.focusTarget($target)) { - event.preventDefault() - } -} + $module.addEventListener('blur', () => { + $module.removeAttribute('tabindex') + }) -/** - * Focus the target element - * - * By default, the browser will scroll the target into view. Because our labels - * or legends appear above the input, this means the user will be presented with - * an input without any context, as the label or legend will be off the top of - * the screen. - * - * Manually handling the click event, scrolling the question into view and then - * focussing the element solves this. - * - * This also results in the label and/or legend being announced correctly in - * NVDA (as tested in 2018.3.2) - without this only the field type is announced - * (e.g. "Edit, has autocomplete"). - * - * @deprecated Will be made private in v5.0 - * @param {EventTarget} $target - Event target - * @returns {boolean} True if the target was able to be focussed - */ -ErrorSummary.prototype.focusTarget = function ($target) { - // If the element that was clicked was not a link, return early - if (!($target instanceof HTMLAnchorElement)) { - return false + $module.focus() } - const inputId = this.getFragmentFromUrl($target.href) - if (!inputId) { - return false + /** + * Click event handler + * + * @deprecated Will be made private in v5.0 + * @param {MouseEvent} event - Click event + */ + handleClick (event) { + const $target = event.target + if (this.focusTarget($target)) { + event.preventDefault() + } } - const $input = document.getElementById(inputId) - if (!$input) { - return false - } + /** + * Focus the target element + * + * By default, the browser will scroll the target into view. Because our labels + * or legends appear above the input, this means the user will be presented with + * an input without any context, as the label or legend will be off the top of + * the screen. + * + * Manually handling the click event, scrolling the question into view and then + * focussing the element solves this. + * + * This also results in the label and/or legend being announced correctly in + * NVDA (as tested in 2018.3.2) - without this only the field type is announced + * (e.g. "Edit, has autocomplete"). + * + * @deprecated Will be made private in v5.0 + * @param {EventTarget} $target - Event target + * @returns {boolean} True if the target was able to be focussed + */ + focusTarget ($target) { + // If the element that was clicked was not a link, return early + if (!($target instanceof HTMLAnchorElement)) { + return false + } - const $legendOrLabel = this.getAssociatedLegendOrLabel($input) - if (!$legendOrLabel) { - return false - } + const inputId = this.getFragmentFromUrl($target.href) + if (!inputId) { + return false + } - // Scroll the legend or label into view *before* calling focus on the input to - // avoid extra scrolling in browsers that don't support `preventScroll` (which - // at time of writing is most of them...) - $legendOrLabel.scrollIntoView() - $input.focus({ preventScroll: true }) + const $input = document.getElementById(inputId) + if (!$input) { + return false + } - return true -} + const $legendOrLabel = this.getAssociatedLegendOrLabel($input) + if (!$legendOrLabel) { + return false + } -/** - * Get fragment from URL - * - * Extract the fragment (everything after the hash) from a URL, but not including - * the hash. - * - * @deprecated Will be made private in v5.0 - * @param {string} url - URL - * @returns {string | undefined} Fragment from URL, without the hash - */ -ErrorSummary.prototype.getFragmentFromUrl = function (url) { - if (url.indexOf('#') === -1) { - return undefined + // Scroll the legend or label into view *before* calling focus on the input to + // avoid extra scrolling in browsers that don't support `preventScroll` (which + // at time of writing is most of them...) + $legendOrLabel.scrollIntoView() + $input.focus({ preventScroll: true }) + + return true } - return url.split('#').pop() -} + /** + * Get fragment from URL + * + * Extract the fragment (everything after the hash) from a URL, but not including + * the hash. + * + * @deprecated Will be made private in v5.0 + * @param {string} url - URL + * @returns {string | undefined} Fragment from URL, without the hash + */ + getFragmentFromUrl (url) { + if (url.indexOf('#') === -1) { + return undefined + } -/** - * Get associated legend or label - * - * Returns the first element that exists from this list: - * - * - The `` associated with the closest `
` ancestor, as long - * as the top of it is no more than half a viewport height away from the - * bottom of the input - * - The first `