UNPKG

govuk-frontend

Version:

GOV.UK Frontend contains the code you need to start building a user interface for government platforms and services.

1,279 lines (1,252 loc) 102 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = global.GOVUKFrontend || {})); })(this, (function (exports) { 'use strict'; const version = '6.1.0'; function getBreakpoint(name) { const property = `--govuk-breakpoint-${name}`; const value = window.getComputedStyle(document.documentElement).getPropertyValue(property); return { property, value: value || undefined }; } function setFocus($element, options = {}) { var _options$onBeforeFocu; const isFocusable = $element.getAttribute('tabindex'); if (!isFocusable) { $element.setAttribute('tabindex', '-1'); } function onFocus() { $element.addEventListener('blur', onBlur, { once: true }); } function onBlur() { var _options$onBlur; (_options$onBlur = options.onBlur) == null || _options$onBlur.call($element); if (!isFocusable) { $element.removeAttribute('tabindex'); } } $element.addEventListener('focus', onFocus, { once: true }); (_options$onBeforeFocu = options.onBeforeFocus) == null || _options$onBeforeFocu.call($element); $element.focus(); } function isInitialised($root, moduleName) { return $root instanceof HTMLElement && $root.hasAttribute(`data-${moduleName}-init`); } /** * Checks if GOV.UK Frontend is supported on this page * * Some browsers will load and run our JavaScript but GOV.UK Frontend * won't be supported. * * @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support * @returns {boolean} Whether GOV.UK Frontend is supported on this page */ function isSupported($scope = document.body) { if (!$scope) { return false; } return $scope.classList.contains('govuk-frontend-supported'); } function isArray(option) { return Array.isArray(option); } function isObject(option) { return !!option && typeof option === 'object' && !isArray(option); } function isScope($scope) { return !!$scope && ($scope instanceof Element || $scope instanceof Document); } function formatErrorMessage(Component, message) { return `${Component.moduleName}: ${message}`; } /** * @typedef ComponentWithModuleName * @property {string} moduleName - Name of the component */ class GOVUKFrontendError extends Error { constructor(...args) { super(...args); this.name = 'GOVUKFrontendError'; } } class SupportError extends GOVUKFrontendError { /** * Checks if GOV.UK Frontend is supported on this page * * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support */ constructor($scope = document.body) { const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser'; super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`'); this.name = 'SupportError'; } } class ConfigError extends GOVUKFrontendError { constructor(...args) { super(...args); this.name = 'ConfigError'; } } class ElementError extends GOVUKFrontendError { constructor(messageOrOptions) { let message = typeof messageOrOptions === 'string' ? messageOrOptions : ''; if (isObject(messageOrOptions)) { const { component, identifier, element, expectedType } = messageOrOptions; message = identifier; message += element ? ` is not of type ${expectedType != null ? expectedType : 'HTMLElement'}` : ' not found'; if (component) { message = formatErrorMessage(component, message); } } super(message); this.name = 'ElementError'; } } class InitError extends GOVUKFrontendError { constructor(componentOrMessage) { const message = typeof componentOrMessage === 'string' ? componentOrMessage : formatErrorMessage(componentOrMessage, `Root element (\`$root\`) already initialised`); super(message); this.name = 'InitError'; } } /** * @import { ComponentWithModuleName } from '../common/index.mjs' */ class Component { /** * Returns the root element of the component * * @protected * @returns {RootElementType} - the root element of component */ get $root() { return this._$root; } constructor($root) { this._$root = void 0; const childConstructor = this.constructor; if (typeof childConstructor.moduleName !== 'string') { throw new InitError(`\`moduleName\` not defined in component`); } if (!($root instanceof childConstructor.elementType)) { throw new ElementError({ element: $root, component: childConstructor, identifier: 'Root element (`$root`)', expectedType: childConstructor.elementType.name }); } else { this._$root = $root; } childConstructor.checkSupport(); this.checkInitialised(); const moduleName = childConstructor.moduleName; this.$root.setAttribute(`data-${moduleName}-init`, ''); } checkInitialised() { const constructor = this.constructor; const moduleName = constructor.moduleName; if (moduleName && isInitialised(this.$root, moduleName)) { throw new InitError(constructor); } } static checkSupport() { if (!isSupported()) { throw new SupportError(); } } } /** * @typedef ChildClass * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component */ /** * @typedef {typeof Component & ChildClass} ChildClassConstructor */ Component.elementType = HTMLElement; const configOverride = Symbol.for('configOverride'); class ConfigurableComponent extends Component { [configOverride](param) { return {}; } /** * Returns the root element of the component * * @protected * @returns {ConfigurationType} - the root element of component */ get config() { return this._config; } constructor($root, config) { super($root); this._config = void 0; const childConstructor = this.constructor; if (!isObject(childConstructor.defaults)) { throw new ConfigError(formatErrorMessage(childConstructor, 'Config passed as parameter into constructor but no defaults defined')); } const datasetConfig = normaliseDataset(childConstructor, this._$root.dataset); this._config = mergeConfigs(childConstructor.defaults, config != null ? config : {}, this[configOverride](datasetConfig), datasetConfig); } } function normaliseString(value, property) { const trimmedValue = value ? value.trim() : ''; let output; let outputType = property == null ? void 0 : property.type; if (!outputType) { if (['true', 'false'].includes(trimmedValue)) { outputType = 'boolean'; } if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) { outputType = 'number'; } } switch (outputType) { case 'boolean': output = trimmedValue === 'true'; break; case 'number': output = Number(trimmedValue); break; default: output = value; } return output; } function normaliseDataset(Component, dataset) { if (!isObject(Component.schema)) { throw new ConfigError(formatErrorMessage(Component, 'Config passed as parameter into constructor but no schema defined')); } const out = {}; const entries = Object.entries(Component.schema.properties); for (const entry of entries) { const [namespace, property] = entry; const field = namespace.toString(); if (field in dataset) { out[field] = normaliseString(dataset[field], property); } if ((property == null ? void 0 : property.type) === 'object') { out[field] = extractConfigByNamespace(Component.schema, dataset, namespace); } } return out; } function normaliseOptions(scopeOrOptions) { let $scope = document; let onError; if (isObject(scopeOrOptions)) { const options = scopeOrOptions; if (isScope(options.scope) || options.scope === null) { $scope = options.scope; } if (typeof options.onError === 'function') { onError = options.onError; } } if (isScope(scopeOrOptions)) { $scope = scopeOrOptions; } else if (scopeOrOptions === null) { $scope = null; } else if (typeof scopeOrOptions === 'function') { onError = scopeOrOptions; } return { scope: $scope, onError }; } function mergeConfigs(...configObjects) { const formattedConfigObject = {}; for (const configObject of configObjects) { for (const key of Object.keys(configObject)) { const option = formattedConfigObject[key]; const override = configObject[key]; if (isObject(option) && isObject(override)) { formattedConfigObject[key] = mergeConfigs(option, override); } else { formattedConfigObject[key] = override; } } } return formattedConfigObject; } function validateConfig(schema, config) { const validationErrors = []; for (const [name, conditions] of Object.entries(schema)) { const errors = []; if (Array.isArray(conditions)) { for (const { required, errorMessage } of conditions) { if (!required.every(key => !!config[key])) { errors.push(errorMessage); } } if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) { validationErrors.push(...errors); } } } return validationErrors; } function extractConfigByNamespace(schema, dataset, namespace) { const property = schema.properties[namespace]; if ((property == null ? void 0 : property.type) !== 'object') { return; } const newObject = { [namespace]: {} }; for (const [key, value] of Object.entries(dataset)) { let current = newObject; const keyParts = key.split('.'); for (const [index, name] of keyParts.entries()) { if (isObject(current)) { if (index < keyParts.length - 1) { if (!isObject(current[name])) { current[name] = {}; } current = current[name]; } else if (key !== namespace) { current[name] = normaliseString(value); } } } } return newObject[namespace]; } /** * Schema for component config * * @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType * @typedef {object} Schema * @property {Record<keyof ConfigurationType, SchemaProperty | undefined>} properties - Schema properties * @property {SchemaCondition<ConfigurationType>[]} [anyOf] - List of schema conditions */ /** * Schema property for component config * * @typedef {object} SchemaProperty * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type */ /** * Schema condition for component config * * @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType * @typedef {object} SchemaCondition * @property {(keyof ConfigurationType)[]} required - List of required config fields * @property {string} errorMessage - Error message when required config fields not provided */ /** * @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested] * @typedef ChildClass * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component * @property {Schema<ConfigurationType>} [schema] - The schema of the component configuration * @property {ConfigurationType} [defaults] - The default values of the configuration of the component */ /** * @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested] * @typedef {typeof Component & ChildClass<ConfigurationType>} ChildClassConstructor<ConfigurationType> */ /** * @import { CompatibleClass, Config, CreateAllOptions, OnErrorCallback } from '../init.mjs' */ class I18n { constructor(translations = {}, config = {}) { var _config$locale; this.translations = void 0; this.locale = void 0; this.translations = translations; this.locale = (_config$locale = config.locale) != null ? _config$locale : document.documentElement.lang || 'en'; } t(lookupKey, options) { if (!lookupKey) { throw new Error('i18n: lookup key missing'); } let translation = this.translations[lookupKey]; if (typeof (options == null ? void 0 : options.count) === 'number' && isObject(translation)) { const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)]; if (translationPluralForm) { translation = translationPluralForm; } } if (typeof translation === 'string') { if (translation.match(/%{(.\S+)}/)) { if (!options) { throw new Error('i18n: cannot replace placeholders in string if no option data provided'); } return this.replacePlaceholders(translation, options); } return translation; } return lookupKey; } replacePlaceholders(translationString, options) { const formatter = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : undefined; return translationString.replace(/%{(.\S+)}/g, function (placeholderWithBraces, placeholderKey) { if (Object.prototype.hasOwnProperty.call(options, placeholderKey)) { const placeholderValue = options[placeholderKey]; if (placeholderValue === false || typeof placeholderValue !== 'number' && typeof placeholderValue !== 'string') { return ''; } if (typeof placeholderValue === 'number') { return formatter ? formatter.format(placeholderValue) : `${placeholderValue}`; } return placeholderValue; } throw new Error(`i18n: no data found to replace ${placeholderWithBraces} placeholder in string`); }); } hasIntlPluralRulesSupport() { return Boolean('PluralRules' in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length); } getPluralSuffix(lookupKey, count) { count = Number(count); if (!isFinite(count)) { return 'other'; } const translation = this.translations[lookupKey]; const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : 'other'; if (isObject(translation)) { if (preferredForm in translation) { return preferredForm; } else if ('other' in translation) { console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`); return 'other'; } } throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`); } } /** * Accordion component * * This allows a collection of sections to be collapsed by default, showing only * their headers. Sections can be expanded or collapsed individually by clicking * their headers. A "Show all sections" button is also added to the top of the * accordion, which switches to "Hide all sections" when all the sections are * expanded. * * The state of each section is saved to the DOM via the `aria-expanded` * attribute, which also provides accessibility. * * @preserve * @augments ConfigurableComponent<AccordionConfig> */ class Accordion extends ConfigurableComponent { /** * @param {Element | null} $root - HTML element to use for accordion * @param {AccordionConfig} [config] - Accordion config */ constructor($root, config = {}) { super($root, config); this.i18n = void 0; this.controlsClass = 'govuk-accordion__controls'; this.showAllClass = 'govuk-accordion__show-all'; this.showAllTextClass = 'govuk-accordion__show-all-text'; this.sectionClass = 'govuk-accordion__section'; this.sectionExpandedClass = 'govuk-accordion__section--expanded'; this.sectionButtonClass = 'govuk-accordion__section-button'; this.sectionHeaderClass = 'govuk-accordion__section-header'; this.sectionHeadingClass = 'govuk-accordion__section-heading'; this.sectionHeadingDividerClass = 'govuk-accordion__section-heading-divider'; this.sectionHeadingTextClass = 'govuk-accordion__section-heading-text'; this.sectionHeadingTextFocusClass = 'govuk-accordion__section-heading-text-focus'; this.sectionShowHideToggleClass = 'govuk-accordion__section-toggle'; this.sectionShowHideToggleFocusClass = 'govuk-accordion__section-toggle-focus'; this.sectionShowHideTextClass = 'govuk-accordion__section-toggle-text'; this.upChevronIconClass = 'govuk-accordion-nav__chevron'; this.downChevronIconClass = 'govuk-accordion-nav__chevron--down'; this.sectionSummaryClass = 'govuk-accordion__section-summary'; this.sectionSummaryFocusClass = 'govuk-accordion__section-summary-focus'; this.sectionContentClass = 'govuk-accordion__section-content'; this.$sections = void 0; this.$showAllButton = null; this.$showAllIcon = null; this.$showAllText = null; this.i18n = new I18n(this.config.i18n); const $sections = this.$root.querySelectorAll(`.${this.sectionClass}`); if (!$sections.length) { throw new ElementError({ component: Accordion, identifier: `Sections (\`<div class="${this.sectionClass}">\`)` }); } this.$sections = $sections; this.initControls(); this.initSectionHeaders(); this.updateShowAllButton(this.areAllSectionsOpen()); } initControls() { this.$showAllButton = document.createElement('button'); this.$showAllButton.setAttribute('type', 'button'); this.$showAllButton.setAttribute('class', this.showAllClass); this.$showAllButton.setAttribute('aria-expanded', 'false'); this.$showAllIcon = document.createElement('span'); this.$showAllIcon.classList.add(this.upChevronIconClass); this.$showAllButton.appendChild(this.$showAllIcon); const $accordionControls = document.createElement('div'); $accordionControls.setAttribute('class', this.controlsClass); $accordionControls.appendChild(this.$showAllButton); this.$root.insertBefore($accordionControls, this.$root.firstChild); this.$showAllText = document.createElement('span'); this.$showAllText.classList.add(this.showAllTextClass); this.$showAllButton.appendChild(this.$showAllText); this.$showAllButton.addEventListener('click', () => this.onShowOrHideAllToggle()); if ('onbeforematch' in document) { document.addEventListener('beforematch', event => this.onBeforeMatch(event)); } } initSectionHeaders() { this.$sections.forEach(($section, i) => { const $header = $section.querySelector(`.${this.sectionHeaderClass}`); if (!$header) { throw new ElementError({ component: Accordion, identifier: `Section headers (\`<div class="${this.sectionHeaderClass}">\`)` }); } this.constructHeaderMarkup($header, i); this.setExpanded(this.isExpanded($section), $section); $header.addEventListener('click', () => this.onSectionToggle($section)); this.setInitialState($section); }); } constructHeaderMarkup($header, index) { const $span = $header.querySelector(`.${this.sectionButtonClass}`); const $heading = $header.querySelector(`.${this.sectionHeadingClass}`); const $summary = $header.querySelector(`.${this.sectionSummaryClass}`); if (!$heading) { throw new ElementError({ component: Accordion, identifier: `Section heading (\`.${this.sectionHeadingClass}\`)` }); } if (!$span) { throw new ElementError({ component: Accordion, identifier: `Section button placeholder (\`<span class="${this.sectionButtonClass}">\`)` }); } const $button = document.createElement('button'); $button.setAttribute('type', 'button'); $button.setAttribute('aria-controls', `${this.$root.id}-content-${index + 1}`); for (const attr of Array.from($span.attributes)) { if (attr.name !== 'id') { $button.setAttribute(attr.name, attr.value); } } const $headingText = document.createElement('span'); $headingText.classList.add(this.sectionHeadingTextClass); $headingText.id = $span.id; const $headingTextFocus = document.createElement('span'); $headingTextFocus.classList.add(this.sectionHeadingTextFocusClass); $headingText.appendChild($headingTextFocus); Array.from($span.childNodes).forEach($child => $headingTextFocus.appendChild($child)); const $showHideToggle = document.createElement('span'); $showHideToggle.classList.add(this.sectionShowHideToggleClass); $showHideToggle.setAttribute('data-nosnippet', ''); const $showHideToggleFocus = document.createElement('span'); $showHideToggleFocus.classList.add(this.sectionShowHideToggleFocusClass); $showHideToggle.appendChild($showHideToggleFocus); 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); $button.appendChild($headingText); $button.appendChild(this.getButtonPunctuationEl()); if ($summary) { const $summarySpan = document.createElement('span'); const $summarySpanFocus = document.createElement('span'); $summarySpanFocus.classList.add(this.sectionSummaryFocusClass); $summarySpan.appendChild($summarySpanFocus); for (const attr of Array.from($summary.attributes)) { $summarySpan.setAttribute(attr.name, attr.value); } Array.from($summary.childNodes).forEach($child => $summarySpanFocus.appendChild($child)); $summary.remove(); $button.appendChild($summarySpan); $button.appendChild(this.getButtonPunctuationEl()); } $button.appendChild($showHideToggle); $heading.removeChild($span); $heading.appendChild($button); } onBeforeMatch(event) { const $fragment = event.target; if (!($fragment instanceof Element)) { return; } const $section = $fragment.closest(`.${this.sectionClass}`); if ($section) { this.setExpanded(true, $section); } } onSectionToggle($section) { const nowExpanded = !this.isExpanded($section); this.setExpanded(nowExpanded, $section); this.storeState($section, nowExpanded); } onShowOrHideAllToggle() { const nowExpanded = !this.areAllSectionsOpen(); this.$sections.forEach($section => { this.setExpanded(nowExpanded, $section); this.storeState($section, nowExpanded); }); this.updateShowAllButton(nowExpanded); } 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}`); if (!$content) { throw new ElementError({ component: Accordion, identifier: `Section content (\`<div class="${this.sectionContentClass}">\`)` }); } if (!$showHideIcon || !$showHideText || !$button) { return; } const newButtonText = expanded ? this.i18n.t('hideSection') : this.i18n.t('showSection'); $showHideText.textContent = newButtonText; $button.setAttribute('aria-expanded', `${expanded}`); const ariaLabelParts = []; const $headingText = $section.querySelector(`.${this.sectionHeadingTextClass}`); if ($headingText) { ariaLabelParts.push($headingText.textContent.trim()); } const $summary = $section.querySelector(`.${this.sectionSummaryClass}`); if ($summary) { ariaLabelParts.push($summary.textContent.trim()); } const ariaLabelMessage = expanded ? this.i18n.t('hideSectionAriaLabel') : this.i18n.t('showSectionAriaLabel'); ariaLabelParts.push(ariaLabelMessage); $button.setAttribute('aria-label', ariaLabelParts.join(' , ')); 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); } this.updateShowAllButton(this.areAllSectionsOpen()); } isExpanded($section) { return $section.classList.contains(this.sectionExpandedClass); } areAllSectionsOpen() { return Array.from(this.$sections).every($section => this.isExpanded($section)); } updateShowAllButton(expanded) { if (!this.$showAllButton || !this.$showAllText || !this.$showAllIcon) { return; } this.$showAllButton.setAttribute('aria-expanded', expanded.toString()); this.$showAllText.textContent = expanded ? this.i18n.t('hideAllSections') : this.i18n.t('showAllSections'); this.$showAllIcon.classList.toggle(this.downChevronIconClass, !expanded); } /** * Get the identifier for a section * * 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. * * @param {Element} $section - Section element * @returns {string | undefined | null} Identifier for section */ getIdentifier($section) { const $button = $section.querySelector(`.${this.sectionButtonClass}`); return $button == null ? void 0 : $button.getAttribute('aria-controls'); } storeState($section, isExpanded) { if (!this.config.rememberExpanded) { return; } const id = this.getIdentifier($section); if (id) { try { window.sessionStorage.setItem(id, isExpanded.toString()); } catch (_unused) {} } } setInitialState($section) { if (!this.config.rememberExpanded) { return; } const id = this.getIdentifier($section); if (id) { try { const state = window.sessionStorage.getItem(id); if (state !== null) { this.setExpanded(state === 'true', $section); } } catch (_unused2) {} } } getButtonPunctuationEl() { const $punctuationEl = document.createElement('span'); $punctuationEl.classList.add('govuk-visually-hidden', this.sectionHeadingDividerClass); $punctuationEl.textContent = ', '; return $punctuationEl; } } /** * Accordion config * * @see {@link Accordion.defaults} * @typedef {object} AccordionConfig * @property {AccordionTranslations} [i18n=Accordion.defaults.i18n] - Accordion translations * @property {boolean} [rememberExpanded] - Whether the expanded and collapsed * state of each section is remembered and restored when navigating. */ /** * Accordion translations * * @see {@link Accordion.defaults.i18n} * @typedef {object} AccordionTranslations * * Messages used by the component for the labels of its buttons. This includes * the visible text shown on screen, and text to help assistive technology users * for the buttons toggling each section. * @property {string} [hideAllSections] - The text content for the 'Hide all * sections' button, used when at least one section is expanded. * @property {string} [hideSection] - The text content for the 'Hide' * button, used when a section is expanded. * @property {string} [hideSectionAriaLabel] - The text content appended to the * 'Hide' button's accessible name when a section is expanded. * @property {string} [showAllSections] - The text content for the 'Show all * sections' button, used when all sections are collapsed. * @property {string} [showSection] - The text content for the 'Show' * button, used when a section is collapsed. * @property {string} [showSectionAriaLabel] - The text content appended to the * 'Show' button's accessible name when a section is expanded. */ /** * @import { Schema } from '../../common/configuration.mjs' */ Accordion.moduleName = 'govuk-accordion'; Accordion.defaults = Object.freeze({ i18n: { hideAllSections: 'Hide all sections', hideSection: 'Hide', hideSectionAriaLabel: 'Hide this section', showAllSections: 'Show all sections', showSection: 'Show', showSectionAriaLabel: 'Show this section' }, rememberExpanded: true }); Accordion.schema = Object.freeze({ properties: { i18n: { type: 'object' }, rememberExpanded: { type: 'boolean' } } }); const DEBOUNCE_TIMEOUT_IN_SECONDS = 1; /** * JavaScript enhancements for the Button component * * @preserve * @augments ConfigurableComponent<ButtonConfig> */ class Button extends ConfigurableComponent { /** * @param {Element | null} $root - HTML element to use for button * @param {ButtonConfig} [config] - Button config */ constructor($root, config = {}) { super($root, config); this.debounceFormSubmitTimer = null; this.$root.addEventListener('keydown', event => this.handleKeyDown(event)); this.$root.addEventListener('click', event => this.debounce(event)); } handleKeyDown(event) { const $target = event.target; if (event.key !== ' ') { return; } if ($target instanceof HTMLElement && $target.getAttribute('role') === 'button') { event.preventDefault(); $target.click(); } } debounce(event) { if (!this.config.preventDoubleClick) { return; } if (this.debounceFormSubmitTimer) { event.preventDefault(); return false; } this.debounceFormSubmitTimer = window.setTimeout(() => { this.debounceFormSubmitTimer = null; }, DEBOUNCE_TIMEOUT_IN_SECONDS * 1000); } } /** * Button config * * @typedef {object} ButtonConfig * @property {boolean} [preventDoubleClick=false] - Prevent accidental double * clicks on submit buttons from submitting forms multiple times. */ /** * @import { Schema } from '../../common/configuration.mjs' */ Button.moduleName = 'govuk-button'; Button.defaults = Object.freeze({ preventDoubleClick: false }); Button.schema = Object.freeze({ properties: { preventDoubleClick: { type: 'boolean' } } }); function closestAttributeValue($element, attributeName) { const $closestElementWithAttribute = $element.closest(`[${attributeName}]`); return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null; } /** * Character count component * * Tracks the number of characters or words in the `.govuk-js-character-count` * `<textarea>` inside the element. Displays a message with the remaining number * of characters/words available, or the number of characters/words in excess. * * You can configure the message to only appear after a certain percentage * of the available characters/words has been entered. * * @preserve * @augments ConfigurableComponent<CharacterCountConfig> */ class CharacterCount extends ConfigurableComponent { [configOverride](datasetConfig) { let configOverrides = {}; if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) { configOverrides = { maxlength: undefined, maxwords: undefined }; } return configOverrides; } /** * @param {Element | null} $root - HTML element to use for character count * @param {CharacterCountConfig} [config] - Character count config */ constructor($root, config = {}) { var _ref, _this$config$maxwords; super($root, config); this.$textarea = void 0; this.count = 0; this.$visibleCountMessage = void 0; this.$screenReaderCountMessage = void 0; this.lastInputTimestamp = null; this.lastInputValue = ''; this.valueChecker = null; this.i18n = void 0; this.maxLength = void 0; const $textarea = this.$root.querySelector('.govuk-js-character-count'); if (!($textarea instanceof HTMLTextAreaElement || $textarea instanceof HTMLInputElement)) { throw new ElementError({ component: CharacterCount, element: $textarea, expectedType: 'HTMLTextareaElement or HTMLInputElement', identifier: 'Form field (`.govuk-js-character-count`)' }); } const errors = validateConfig(CharacterCount.schema, this.config); if (errors[0]) { throw new ConfigError(formatErrorMessage(CharacterCount, errors[0])); } this.i18n = new I18n(this.config.i18n, { locale: closestAttributeValue(this.$root, 'lang') }); this.maxLength = (_ref = (_this$config$maxwords = this.config.maxwords) != null ? _this$config$maxwords : this.config.maxlength) != null ? _ref : Infinity; this.$textarea = $textarea; const textareaDescriptionId = `${this.$textarea.id}-info`; const $textareaDescription = document.getElementById(textareaDescriptionId); if (!$textareaDescription) { throw new ElementError({ component: CharacterCount, element: $textareaDescription, identifier: `Count message (\`id="${textareaDescriptionId}"\`)` }); } this.$errorMessage = this.$root.querySelector('.govuk-error-message'); if ($textareaDescription.textContent.match(/^\s*$/)) { $textareaDescription.textContent = this.i18n.t('textareaDescription', { count: this.maxLength }); } this.$textarea.insertAdjacentElement('afterend', $textareaDescription); 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); 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); $textareaDescription.classList.add('govuk-visually-hidden'); this.$textarea.removeAttribute('maxlength'); this.bindChangeEvents(); window.addEventListener('pageshow', () => { if (this.$textarea.value !== this.$textarea.textContent) { this.updateCount(); this.updateCountMessage(); } }); this.updateCount(); this.updateCountMessage(); } bindChangeEvents() { this.$textarea.addEventListener('input', () => this.handleInput()); this.$textarea.addEventListener('focus', () => this.handleFocus()); this.$textarea.addEventListener('blur', () => this.handleBlur()); } handleInput() { this.updateCount(); this.updateVisibleCountMessage(); this.lastInputTimestamp = Date.now(); } handleFocus() { this.valueChecker = window.setInterval(() => { if (!this.lastInputTimestamp || Date.now() - 500 >= this.lastInputTimestamp) { this.updateIfValueChanged(); } }, 1000); } handleBlur() { if (this.valueChecker) { window.clearInterval(this.valueChecker); } } updateIfValueChanged() { if (this.$textarea.value !== this.lastInputValue) { this.lastInputValue = this.$textarea.value; this.updateCountMessage(); } } updateCountMessage() { this.updateVisibleCountMessage(); this.updateScreenReaderCountMessage(); } updateVisibleCountMessage() { const remainingNumber = this.maxLength - this.count; const isError = remainingNumber < 0; this.$visibleCountMessage.classList.toggle('govuk-character-count__message--disabled', !this.isOverThreshold()); if (!this.$errorMessage) { this.$textarea.classList.toggle('govuk-textarea--error', isError); } this.$visibleCountMessage.classList.toggle('govuk-error-message', isError); this.$visibleCountMessage.classList.toggle('govuk-hint', !isError); this.$visibleCountMessage.textContent = this.getCountMessage(); } updateScreenReaderCountMessage() { if (this.isOverThreshold()) { this.$screenReaderCountMessage.removeAttribute('aria-hidden'); } else { this.$screenReaderCountMessage.setAttribute('aria-hidden', 'true'); } this.$screenReaderCountMessage.textContent = this.getCountMessage(); } updateCount() { const text = this.$textarea.value; if (this.config.maxwords) { var _text$match; const tokens = (_text$match = text.match(/\S+/g)) != null ? _text$match : []; this.count = tokens.length; return; } this.count = text.length; } getCountMessage() { const remainingNumber = this.maxLength - this.count; const countType = this.config.maxwords ? 'words' : 'characters'; return this.formatCountMessage(remainingNumber, countType); } formatCountMessage(remainingNumber, countType) { if (remainingNumber === 0) { return this.i18n.t(`${countType}AtLimit`); } const translationKeySuffix = remainingNumber < 0 ? 'OverLimit' : 'UnderLimit'; return this.i18n.t(`${countType}${translationKeySuffix}`, { count: Math.abs(remainingNumber) }); } isOverThreshold() { if (!this.config.threshold) { return true; } const currentLength = this.count; const maxLength = this.maxLength; const thresholdValue = maxLength * this.config.threshold / 100; return thresholdValue <= currentLength; } } /** * Character count config * * @see {@link CharacterCount.defaults} * @typedef {object} CharacterCountConfig * @property {number} [maxlength] - The maximum number of characters. * If maxwords is provided, the maxlength option will be ignored. * @property {number} [maxwords] - The maximum number of words. If maxwords is * provided, the maxlength option will be ignored. * @property {number} [threshold=0] - The percentage value of the limit at * which point the count message is displayed. If this attribute is set, the * count message will be hidden by default. * @property {CharacterCountTranslations} [i18n=CharacterCount.defaults.i18n] - Character count translations */ /** * Character count translations * * @see {@link CharacterCount.defaults.i18n} * @typedef {object} CharacterCountTranslations * * Messages shown to users as they type. It provides feedback on how many words * or characters they have remaining or if they are over the limit. This also * includes a message used as an accessible description for the textarea. * @property {TranslationPluralForms} [charactersUnderLimit] - Message displayed * when the number of characters is under the configured maximum, `maxlength`. * This message is displayed visually and through assistive technologies. The * component will replace the `%{count}` placeholder with the number of * remaining characters. This is a [pluralised list of * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend). * @property {string} [charactersAtLimit] - Message displayed when the number of * characters reaches the configured maximum, `maxlength`. This message is * displayed visually and through assistive technologies. * @property {TranslationPluralForms} [charactersOverLimit] - Message displayed * when the number of characters is over the configured maximum, `maxlength`. * This message is displayed visually and through assistive technologies. The * component will replace the `%{count}` placeholder with the number of * remaining characters. This is a [pluralised list of * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend). * @property {TranslationPluralForms} [wordsUnderLimit] - Message displayed when * the number of words is under the configured maximum, `maxlength`. This * message is displayed visually and through assistive technologies. The * component will replace the `%{count}` placeholder with the number of * remaining words. This is a [pluralised list of * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend). * @property {string} [wordsAtLimit] - Message displayed when the number of * words reaches the configured maximum, `maxlength`. This message is * displayed visually and through assistive technologies. * @property {TranslationPluralForms} [wordsOverLimit] - Message displayed when * the number of words is over the configured maximum, `maxlength`. This * message is displayed visually and through assistive technologies. The * component will replace the `%{count}` placeholder with the number of * remaining words. This is a [pluralised list of * messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend). * @property {TranslationPluralForms} [textareaDescription] - Message made * available to assistive technologies, if none is already present in the * HTML, to describe that the component accepts only a limited amount of * content. It is visible on the page when JavaScript is unavailable. The * component will replace the `%{count}` placeholder with the value of the * `maxlength` or `maxwords` parameter. */ /** * @import { Schema } from '../../common/configuration.mjs' * @import { TranslationPluralForms } from '../../i18n.mjs' */ CharacterCount.moduleName = 'govuk-character-count'; CharacterCount.defaults = Object.freeze({ threshold: 0, i18n: { charactersUnderLimit: { one: 'You have %{count} character remaining', other: 'You have %{count} characters remaining' }, charactersAtLimit: 'You have 0 characters remaining', charactersOverLimit: { one: 'You have %{count} character too many', other: 'You have %{count} characters too many' }, wordsUnderLimit: { one: 'You have %{count} word remaining', other: 'You have %{count} words remaining' }, wordsAtLimit: 'You have 0 words remaining', wordsOverLimit: { one: 'You have %{count} word too many', other: 'You have %{count} words too many' }, textareaDescription: { other: '' } } }); CharacterCount.schema = Object.freeze({ properties: { i18n: { type: 'object' }, maxwords: { type: 'number' }, maxlength: { type: 'number' }, threshold: { type: 'number' } }, anyOf: [{ required: ['maxwords'], errorMessage: 'Either "maxlength" or "maxwords" must be provided' }, { required: ['maxlength'], errorMessage: 'Either "maxlength" or "maxwords" must be provided' }] }); /** * Checkboxes component * * @preserve */ class Checkboxes extends 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. * * @param {Element | null} $root - HTML element to use for checkboxes */ constructor($root) { super($root); this.$inputs = void 0; const $inputs = this.$root.querySelectorAll('input[type="checkbox"]'); if (!$inputs.length) { throw new ElementError({ component: Checkboxes, identifier: 'Form inputs (`<input type="checkbox">`)' }); } this.$inputs = $inputs; this.$inputs.forEach($input => { const targetId = $input.getAttribute('data-aria-controls'); if (!targetId) { return; } if (!document.getElementById(targetId)) { throw new ElementError({ component: Checkboxes, identifier: `Conditional reveal (\`id="${targetId}"\`)` }); } $input.setAttribute('aria-controls', targetId); $input.removeAttribute('data-aria-controls'); }); window.addEventListener('pageshow', () => this.syncAllConditionalReveals()); this.syncAllConditionalReveals(); this.$root.addEventListener('click', event => this.handleClick(event)); } syncAllConditionalReveals() { this.$inputs.forEach($input => this.syncConditionalRevealWithInputState($input)); } syncConditionalRevealWithInputState($input) { const targetId = $input.getAttribute('aria-controls'); if (!targetId) { return; } const $target = document.getElementById(targetId); if ($target != null && $target.classList.contains('govuk-checkboxes__conditional')) { const inputIsChecked = $input.checked; $input.setAttribute('aria-expanded', inputIsChecked.toString()); $target.classList.toggle('govuk-checkboxes__conditional--hidden', !inputIsChecked); } } unCheckAllInputsExcept($input) { 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); } }); } unCheckExclusiveInputs($input) { 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); } }); } handleClick(event) { const $clickedInput = event.target; if (!($clickedInput instanceof HTMLInputElement) || $clickedInput.type !== 'checkbox') { return; } const hasAriaControls = $clickedInput.getAttribute('aria-controls'); if (hasAriaControls) { this.syncConditionalRevealWithInputState($clickedInput); } if (!$clickedInput.checked) { return; } const hasBehaviourExclusive = $clickedInput.getAttribute('data-behaviour') === 'exclusive'; if (hasBehaviourExclusive) { this.unCheckAllInputsExcept($clickedInput); } else { this.unCheckExclusiveInputs($clickedInput); } } } Checkboxes.moduleName = 'govuk-checkboxes'; /** * Error summary component * * Takes focus on initialisation for accessible announcement, unless disabled in * configuration. * * @preserve * @augments ConfigurableComponent<ErrorSummaryConfig> */ class ErrorSummary extends ConfigurableComponent { /** * @param {Element | null} $root - HTML element to use for error summary * @param {ErrorSummaryConfig} [config] - Error summary config */ constructor($root, config = {}) { super($root, config); if (!thi