govuk-frontend
Version:
GOV.UK Frontend contains the code you need to start building a user interface for government platforms and services.
412 lines (400 loc) • 13 kB
JavaScript
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 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'
*/
/**
* 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 (!this.config.disableAutoFocus) {
setFocus(this.$root);
}
this.$root.addEventListener('click', event => this.handleClick(event));
}
handleClick(event) {
const $target = event.target;
if ($target && this.focusTarget($target)) {
event.preventDefault();
}
}
focusTarget($target) {
if (!($target instanceof HTMLAnchorElement)) {
return false;
}
const inputId = $target.hash.replace('#', '');
if (!inputId) {
return false;
}
const $input = document.getElementById(inputId);
if (!$input) {
return false;
}
const $legendOrLabel = this.getAssociatedLegendOrLabel($input);
if (!$legendOrLabel) {
return false;
}
$legendOrLabel.scrollIntoView();
$input.focus({
preventScroll: true
});
return true;
}
getAssociatedLegendOrLabel($input) {
var _document$querySelect;
const $fieldset = $input.closest('fieldset');
if ($fieldset) {
const $legends = $fieldset.getElementsByTagName('legend');
if ($legends.length) {
const $candidateLegend = $legends[0];
if ($input instanceof HTMLInputElement && ($input.type === 'checkbox' || $input.type === 'radio')) {
return $candidateLegend;
}
const legendTop = $candidateLegend.getBoundingClientRect().top;
const inputRect = $input.getBoundingClientRect();
if (inputRect.height && window.innerHeight) {
const inputBottom = inputRect.top + inputRect.height;
if (inputBottom - legendTop < window.innerHeight / 2) {
return $candidateLegend;
}
}
}
}
return (_document$querySelect = document.querySelector(`label[for='${$input.getAttribute('id')}']`)) != null ? _document$querySelect : $input.closest('label');
}
}
/**
* Error summary config
*
* @typedef {object} ErrorSummaryConfig
* @property {boolean} [disableAutoFocus=false] - If set to `true` the error
* summary will not be focussed when the page loads.
*/
/**
* @import { Schema } from '../../common/configuration.mjs'
*/
ErrorSummary.moduleName = 'govuk-error-summary';
ErrorSummary.defaults = Object.freeze({
disableAutoFocus: false
});
ErrorSummary.schema = Object.freeze({
properties: {
disableAutoFocus: {
type: 'boolean'
}
}
});
export { ErrorSummary };
//# sourceMappingURL=error-summary.bundle.mjs.map