govuk-frontend
Version:
GOV.UK Frontend contains the code you need to start building a user interface for government platforms and services.
651 lines (632 loc) • 24 kB
JavaScript
(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';
function closestAttributeValue($element, attributeName) {
const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
}
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 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 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 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`);
}
}
/**
* File upload component
*
* @preserve
* @augments ConfigurableComponent<FileUploadConfig>
*/
class FileUpload extends ConfigurableComponent {
/**
* @param {Element | null} $root - File input element
* @param {FileUploadConfig} [config] - File Upload config
*/
constructor($root, config = {}) {
super($root, config);
this.$input = void 0;
this.$button = void 0;
this.$status = void 0;
this.i18n = void 0;
this.id = void 0;
this.$announcements = void 0;
this.enteredAnotherElement = void 0;
const $input = this.$root.querySelector('input');
if ($input === null) {
throw new ElementError({
component: FileUpload,
identifier: 'File inputs (`<input type="file">`)'
});
}
if ($input.type !== 'file') {
throw new ElementError(formatErrorMessage(FileUpload, 'File input (`<input type="file">`) attribute (`type`) is not `file`'));
}
this.$input = $input;
if (!this.$input.id) {
throw new ElementError({
component: FileUpload,
identifier: 'File input (`<input type="file">`) attribute (`id`)'
});
}
this.id = this.$input.id;
this.i18n = new I18n(this.config.i18n, {
locale: closestAttributeValue(this.$root, 'lang')
});
const $label = this.findLabel();
if (!$label.id) {
$label.id = `${this.id}-label`;
}
this.$input.id = `${this.id}-input`;
this.$input.setAttribute('hidden', 'true');
const $button = document.createElement('button');
$button.classList.add('govuk-file-upload-button');
$button.type = 'button';
$button.id = this.id;
$button.classList.add('govuk-file-upload-button--empty');
const ariaDescribedBy = this.$input.getAttribute('aria-describedby');
if (ariaDescribedBy) {
$button.setAttribute('aria-describedby', ariaDescribedBy);
}
const $status = document.createElement('span');
$status.className = 'govuk-body govuk-file-upload-button__status';
$status.setAttribute('aria-live', 'polite');
$status.innerText = this.i18n.t('noFileChosen');
$button.appendChild($status);
const commaSpan = document.createElement('span');
commaSpan.className = 'govuk-visually-hidden';
commaSpan.innerText = ', ';
commaSpan.id = `${this.id}-comma`;
$button.appendChild(commaSpan);
const containerSpan = document.createElement('span');
containerSpan.className = 'govuk-file-upload-button__pseudo-button-container';
const buttonSpan = document.createElement('span');
buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload-button__pseudo-button';
buttonSpan.innerText = this.i18n.t('chooseFilesButton');
containerSpan.appendChild(buttonSpan);
containerSpan.insertAdjacentText('beforeend', ' ');
const instructionSpan = document.createElement('span');
instructionSpan.className = 'govuk-body govuk-file-upload-button__instruction';
instructionSpan.innerText = this.i18n.t('dropInstruction');
containerSpan.appendChild(instructionSpan);
$button.appendChild(containerSpan);
$button.setAttribute('aria-labelledby', `${$label.id} ${commaSpan.id} ${$button.id}`);
$button.addEventListener('click', this.onClick.bind(this));
$button.addEventListener('dragover', event => {
event.preventDefault();
});
this.$root.insertAdjacentElement('afterbegin', $button);
this.$input.setAttribute('tabindex', '-1');
this.$input.setAttribute('aria-hidden', 'true');
this.$button = $button;
this.$status = $status;
this.$input.addEventListener('change', this.onChange.bind(this));
this.updateDisabledState();
this.observeDisabledState();
this.$announcements = document.createElement('span');
this.$announcements.classList.add('govuk-file-upload-announcements');
this.$announcements.classList.add('govuk-visually-hidden');
this.$announcements.setAttribute('aria-live', 'assertive');
this.$root.insertAdjacentElement('afterend', this.$announcements);
this.$button.addEventListener('drop', this.onDrop.bind(this));
document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
document.addEventListener('dragenter', () => {
this.enteredAnotherElement = true;
});
document.addEventListener('dragleave', () => {
if (!this.enteredAnotherElement && !this.$button.disabled) {
this.hideDraggingState();
this.$announcements.innerText = this.i18n.t('leftDropZone');
}
this.enteredAnotherElement = false;
});
}
updateDropzoneVisibility(event) {
if (this.$button.disabled) return;
if (event.target instanceof Node) {
if (this.$root.contains(event.target)) {
if (event.dataTransfer && this.canDrop(event.dataTransfer)) {
if (!this.$button.classList.contains('govuk-file-upload-button--dragging')) {
this.showDraggingState();
this.$announcements.innerText = this.i18n.t('enteredDropZone');
}
}
} else {
if (this.$button.classList.contains('govuk-file-upload-button--dragging')) {
this.hideDraggingState();
this.$announcements.innerText = this.i18n.t('leftDropZone');
}
}
}
}
showDraggingState() {
this.$button.classList.add('govuk-file-upload-button--dragging');
}
hideDraggingState() {
this.$button.classList.remove('govuk-file-upload-button--dragging');
}
onDrop(event) {
event.preventDefault();
if (event.dataTransfer && this.canFillInput(event.dataTransfer)) {
this.$input.files = event.dataTransfer.files;
this.$input.dispatchEvent(new CustomEvent('change'));
this.hideDraggingState();
}
}
canFillInput(dataTransfer) {
return this.matchesInputCapacity(dataTransfer.files.length);
}
canDrop(dataTransfer) {
if (dataTransfer.items.length) {
return this.matchesInputCapacity(countFileItems(dataTransfer.items));
}
if (dataTransfer.types.length) {
return dataTransfer.types.includes('Files');
}
return true;
}
matchesInputCapacity(numberOfFiles) {
if (this.$input.multiple) {
return numberOfFiles > 0;
}
return numberOfFiles === 1;
}
onChange() {
const fileCount = this.$input.files.length;
if (fileCount === 0) {
this.$status.innerText = this.i18n.t('noFileChosen');
this.$button.classList.add('govuk-file-upload-button--empty');
} else {
if (fileCount === 1) {
this.$status.innerText = this.$input.files[0].name;
} else {
this.$status.innerText = this.i18n.t('multipleFilesChosen', {
count: fileCount
});
}
this.$button.classList.remove('govuk-file-upload-button--empty');
}
}
findLabel() {
const $label = document.querySelector(`label[for="${this.$input.id}"]`);
if (!$label) {
throw new ElementError({
component: FileUpload,
identifier: `Field label (\`<label for=${this.$input.id}>\`)`
});
}
return $label;
}
onClick() {
this.$input.click();
}
observeDisabledState() {
const observer = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
this.updateDisabledState();
}
}
});
observer.observe(this.$input, {
attributes: true
});
}
updateDisabledState() {
this.$button.disabled = this.$input.disabled;
this.$root.classList.toggle('govuk-drop-zone--disabled', this.$button.disabled);
}
}
/**
* Counts the number of `DataTransferItem` whose kind is `file`
*
* @param {DataTransferItemList} list - The list
* @returns {number} - The number of items whose kind is `file` in the list
*/
FileUpload.moduleName = 'govuk-file-upload';
FileUpload.defaults = Object.freeze({
i18n: {
chooseFilesButton: 'Choose file',
dropInstruction: 'or drop file',
noFileChosen: 'No file chosen',
multipleFilesChosen: {
one: '%{count} file chosen',
other: '%{count} files chosen'
},
enteredDropZone: 'Entered drop zone',
leftDropZone: 'Left drop zone'
}
});
FileUpload.schema = Object.freeze({
properties: {
i18n: {
type: 'object'
}
}
});
function countFileItems(list) {
let result = 0;
for (let i = 0; i < list.length; i++) {
if (list[i].kind === 'file') {
result++;
}
}
return result;
}
/**
* @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
*/
/**
* File upload config
*
* @see {@link FileUpload.defaults}
* @typedef {object} FileUploadConfig
* @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
*/
/**
* File upload translations
*
* @see {@link FileUpload.defaults.i18n}
* @typedef {object} FileUploadTranslations
*
* Messages used by the component
* @property {string} [chooseFile] - The text of the button that opens the file picker
* @property {string} [dropInstruction] - The text informing users they can drop files
* @property {TranslationPluralForms} [multipleFilesChosen] - The text displayed when multiple files
* have been chosen by the user
* @property {string} [noFileChosen] - The text to displayed when no file has been chosen by the user
* @property {string} [enteredDropZone] - The text announced by assistive technology
* when user drags files and enters the drop zone
* @property {string} [leftDropZone] - The text announced by assistive technology
* when user drags files and leaves the drop zone without dropping
*/
/**
* @import { Schema } from '../../common/configuration.mjs'
* @import { TranslationPluralForms } from '../../i18n.mjs'
*/
exports.FileUpload = FileUpload;
}));
//# sourceMappingURL=file-upload.bundle.js.map