Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spike into Config class if defined by child class of GOVUKFrontendComponent #5427

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

patrickpatrickpatrick
Copy link
Contributor

@patrickpatrickpatrick patrickpatrickpatrick commented Oct 23, 2024

In this spike, the Config is defined in the child component. As well as accepting a Component class (with a defined schema and defined defaults) and set of config objects, it also requires the dataset of the root of the component (for normaliseDataset and override functionality.

Copy link

📋 Stats

File sizes

File Size
dist/govuk-frontend-development.min.css 118.41 KiB
dist/govuk-frontend-development.min.js 43.56 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 93.62 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 87.93 KiB
packages/govuk-frontend/dist/govuk/all.mjs 1.18 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend-component.mjs 1.74 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.css 118.4 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.js 43.55 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB
packages/govuk-frontend/dist/govuk/init.mjs 6.85 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 84.06 KiB 41.03 KiB
accordion.mjs 27.17 KiB 13.51 KiB
button.mjs 9.69 KiB 3.89 KiB
character-count.mjs 26.13 KiB 11.05 KiB
checkboxes.mjs 7.81 KiB 3.42 KiB
error-summary.mjs 9.87 KiB 4.07 KiB
exit-this-page.mjs 19.08 KiB 9.86 KiB
header.mjs 6.46 KiB 3.22 KiB
notification-banner.mjs 8.24 KiB 3.23 KiB
password-input.mjs 17.13 KiB 7.86 KiB
radios.mjs 6.81 KiB 2.98 KiB
service-navigation.mjs 6.44 KiB 3.26 KiB
skip-link.mjs 6.4 KiB 2.76 KiB
tabs.mjs 12.04 KiB 6.67 KiB

View stats and visualisations on the review app


Action run for 629d6ee

Copy link

JavaScript changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
index 0416a210b..0efb65c6b 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
@@ -1,44 +1,44 @@
 const version = "development";
 
 function normaliseString(t, e) {
-    const s = t ? t.trim() : "";
-    let n, i = null == e ? void 0 : e.type;
-    switch (i || (["true", "false"].includes(s) && (i = "boolean"), s.length > 0 && isFinite(Number(s)) && (i = "number")), i) {
+    const n = t ? t.trim() : "";
+    let i, s = null == e ? void 0 : e.type;
+    switch (s || (["true", "false"].includes(n) && (s = "boolean"), n.length > 0 && isFinite(Number(n)) && (s = "number")), s) {
         case "boolean":
-            n = "true" === s;
+            i = "true" === n;
             break;
         case "number":
-            n = Number(s);
+            i = Number(n);
             break;
         default:
-            n = t
+            i = t
     }
-    return n
+    return i
 }
 
 function mergeConfigs(...t) {
     const e = {};
-    for (const s of t)
-        for (const t of Object.keys(s)) {
-            const n = e[t],
-                i = s[t];
-            isObject(n) && isObject(i) ? e[t] = mergeConfigs(n, i) : e[t] = i
+    for (const n of t)
+        for (const t of Object.keys(n)) {
+            const i = e[t],
+                s = n[t];
+            isObject(i) && isObject(s) ? e[t] = mergeConfigs(i, s) : e[t] = s
         }
     return e
 }
 
 function extractConfigByNamespace(Component, t, e) {
-    const s = Component.schema.properties[e];
-    if ("object" !== (null == s ? void 0 : s.type)) return;
-    const n = {
+    const n = Component.schema.properties[e];
+    if ("object" !== (null == n ? void 0 : n.type)) return;
+    const i = {
         [e]: {}
     };
-    for (const [i, o] of Object.entries(t)) {
-        let t = n;
-        const s = i.split(".");
-        for (const [n, r] of s.entries()) "object" == typeof t && (n < s.length - 1 ? (isObject(t[r]) || (t[r] = {}), t = t[r]) : i !== e && (t[r] = normaliseString(o)))
+    for (const [s, o] of Object.entries(t)) {
+        let t = i;
+        const n = s.split(".");
+        for (const [i, r] of n.entries()) "object" == typeof t && (i < n.length - 1 ? (isObject(t[r]) || (t[r] = {}), t = t[r]) : s !== e && (t[r] = normaliseString(o)))
     }
-    return n[e]
+    return i[e]
 }
 
 function getFragmentFromUrl(t) {
@@ -54,20 +54,20 @@ function getBreakpoint(t) {
 }
 
 function setFocus(t, e = {}) {
-    var s;
-    const n = t.getAttribute("tabindex");
+    var n;
+    const i = t.getAttribute("tabindex");
 
     function onBlur() {
-        var s;
-        null == (s = e.onBlur) || s.call(t), n || t.removeAttribute("tabindex")
+        var n;
+        null == (n = e.onBlur) || n.call(t), i || t.removeAttribute("tabindex")
     }
-    n || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
+    i || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
         t.addEventListener("blur", onBlur, {
             once: !0
         })
     }), {
         once: !0
-    }), null == (s = e.onBeforeFocus) || s.call(t), t.focus()
+    }), null == (n = e.onBeforeFocus) || n.call(t), t.focus()
 }
 
 function isSupported(t = document.body) {
@@ -83,12 +83,6 @@ function isObject(t) {
 function formatErrorMessage(Component, t) {
     return `${Component.moduleName}: ${t}`
 }
-
-function normaliseDataset(Component, t) {
-    const e = {};
-    for (const [s, n] of Object.entries(Component.schema.properties)) s in t && (e[s] = normaliseString(t[s], n)), "object" === (null == n ? void 0 : n.type) && (e[s] = extractConfigByNamespace(Component, t, s));
-    return e
-}
 class GOVUKFrontendError extends Error {
     constructor(...t) {
         super(...t), this.name = "GOVUKFrontendError"
@@ -110,12 +104,12 @@ class ElementError extends GOVUKFrontendError {
         let e = "string" == typeof t ? t : "";
         if ("object" == typeof t) {
             const {
-                component: s,
-                identifier: n,
-                element: i,
+                component: n,
+                identifier: i,
+                element: s,
                 expectedType: o
             } = t;
-            e = n, e += i ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(s, e)
+            e = i, e += s ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(n, e)
         }
         super(e), this.name = "ElementError"
     }
@@ -125,6 +119,35 @@ class InitError extends GOVUKFrontendError {
         super("string" == typeof t ? t : formatErrorMessage(t, "Root element (`$root`) already initialised")), this.name = "InitError"
     }
 }
+
+function normaliseDataset(Component, t) {
+    const e = {};
+    for (const [n, i] of Object.entries(Component.schema.properties)) n in t && (e[n] = normaliseString(t[n], i)), "object" === (null == i ? void 0 : i.type) && (e[n] = extractConfigByNamespace(Component, t, n));
+    return e
+}
+class Config {
+    static mergeConfigs(...t) {
+        const e = {};
+        for (const n of t)
+            for (const t of Object.keys(n)) {
+                const i = e[t],
+                    s = n[t];
+                isObject(i) && isObject(s) ? e[t] = Config.mergeConfigs(i, s) : e[t] = s
+            }
+        return e
+    }
+    constructor(t, e, ...n) {
+        if (this.configObject = void 0, this.component = void 0, void 0 === t.defaults) throw new ConfigError("No defaults specified in component");
+        if (void 0 === t.schema) throw new ConfigError("No schema specified in component");
+        this.component = t;
+        const i = normaliseDataset(this.component, e);
+        this.configObject = Config.mergeConfigs(this.component.defaults, ...n, this.component.configOverride ? this.component.configOverride(i) : {}, i);
+        const s = this.configObject;
+        return new Proxy(this, {
+            get: (t, e, n) => Reflect.has(t, e) ? Reflect.get(t, e, n) : s[String(e)]
+        })
+    }
+}
 class GOVUKFrontendComponent {
     get $root() {
         return this._$root
@@ -140,8 +163,8 @@ class GOVUKFrontendComponent {
             expectedType: e.elementType.name
         });
         this._$root = t, e.checkSupport(), this.checkInitialised();
-        const s = e.moduleName;
-        this.$root.setAttribute(`data-${s}-init`, "")
+        const n = e.moduleName;
+        this.$root.setAttribute(`data-${n}-init`, "")
     }
     checkInitialised() {
         const t = this.constructor,
@@ -157,31 +180,31 @@ class GOVUKFrontendComponent {
 GOVUKFrontendComponent.elementType = HTMLElement;
 class I18n {
     constructor(t = {}, e = {}) {
-        var s;
-        this.translations = void 0, this.locale = void 0, this.translations = t, this.locale = null != (s = e.locale) ? s : document.documentElement.lang || "en"
+        var n;
+        this.translations = void 0, this.locale = void 0, this.translations = t, this.locale = null != (n = e.locale) ? n : document.documentElement.lang || "en"
     }
     t(t, e) {
         if (!t) throw new Error("i18n: lookup key missing");
-        let s = this.translations[t];
-        if ("number" == typeof(null == e ? void 0 : e.count) && "object" == typeof s) {
-            const n = s[this.getPluralSuffix(t, e.count)];
-            n && (s = n)
+        let n = this.translations[t];
+        if ("number" == typeof(null == e ? void 0 : e.count) && "object" == typeof n) {
+            const i = n[this.getPluralSuffix(t, e.count)];
+            i && (n = i)
         }
-        if ("string" == typeof s) {
-            if (s.match(/%{(.\S+)}/)) {
+        if ("string" == typeof n) {
+            if (n.match(/%{(.\S+)}/)) {
                 if (!e) throw new Error("i18n: cannot replace placeholders in string if no option data provided");
-                return this.replacePlaceholders(s, e)
+                return this.replacePlaceholders(n, e)
             }
-            return s
+            return n
         }
         return t
     }
     replacePlaceholders(t, e) {
-        const s = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
-        return t.replace(/%{(.\S+)}/g, (function(t, n) {
-            if (Object.prototype.hasOwnProperty.call(e, n)) {
-                const t = e[n];
-                return !1 === t || "number" != typeof t && "string" != typeof t ? "" : "number" == typeof t ? s ? s.format(t) : `${t}` : t
+        const n = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
+        return t.replace(/%{(.\S+)}/g, (function(t, i) {
+            if (Object.prototype.hasOwnProperty.call(e, i)) {
+                const t = e[i];
+                return !1 === t || "number" != typeof t && "string" != typeof t ? "" : "number" == typeof t ? n ? n.format(t) : `${t}` : t
             }
             throw new Error(`i18n: no data found to replace ${t} placeholder in string`)
         }))
@@ -191,11 +214,11 @@ class I18n {
     }
     getPluralSuffix(t, e) {
         if (e = Number(e), !isFinite(e)) return "other";
-        const s = this.translations[t],
-            n = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
-        if ("object" == typeof s) {
-            if (n in s) return n;
-            if ("other" in s) return console.warn(`i18n: Missing plural form ".${n}" for "${this.locale}" locale. Falling back to ".other".`), "other"
+        const n = this.translations[t],
+            i = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
+        if ("object" == typeof n) {
+            if (i in n) return i;
+            if ("other" in n) return console.warn(`i18n: Missing plural form ".${i}" for "${this.locale}" locale. Falling back to ".other".`), "other"
         }
         throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`)
     }
@@ -207,8 +230,8 @@ class I18n {
     getPluralRulesForLocale() {
         const t = this.locale.split("-")[0];
         for (const e in I18n.pluralRulesMap) {
-            const s = I18n.pluralRulesMap[e];
-            if (s.includes(this.locale) || s.includes(t)) return e
+            const n = I18n.pluralRulesMap[e];
+            if (n.includes(this.locale) || n.includes(t)) return e
         }
     }
 }
@@ -230,8 +253,8 @@ I18n.pluralRulesMap = {
     irish: t => 1 === t ? "one" : 2 === t ? "two" : t >= 3 && t <= 6 ? "few" : t >= 7 && t <= 10 ? "many" : "other",
     russian(t) {
         const e = t % 100,
-            s = e % 10;
-        return 1 === s && 11 !== e ? "one" : s >= 2 && s <= 4 && !(e >= 12 && e <= 14) ? "few" : 0 === s || s >= 5 && s <= 9 || e >= 11 && e <= 14 ? "many" : "other"
+            n = e % 10;
+        return 1 === n && 11 !== e ? "one" : n >= 2 && n <= 4 && !(e >= 12 && e <= 14) ? "few" : 0 === n || n >= 5 && n <= 9 || e >= 11 && e <= 14 ? "many" : "other"
     },
     scottish: t => 1 === t || 11 === t ? "one" : 2 === t || 12 === t ? "two" : t >= 3 && t <= 10 || t >= 13 && t <= 19 ? "few" : "other",
     spanish: t => 1 === t ? "one" : t % 1e6 == 0 && 0 !== t ? "many" : "other",
@@ -239,13 +262,13 @@ I18n.pluralRulesMap = {
 };
 class Accordion extends GOVUKFrontendComponent {
     constructor(t, e = {}) {
-        super(t), this.config = void 0, 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.config = mergeConfigs(Accordion.defaults, e, normaliseDataset(Accordion, this.$root.dataset)), this.i18n = new I18n(this.config.i18n);
-        const s = this.$root.querySelectorAll(`.${this.sectionClass}`);
-        if (!s.length) throw new ElementError({
+        super(t), this.config = void 0, 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.config = new Config(Accordion, this.$root.dataset, e), this.i18n = new I18n(this.config.i18n);
+        const n = this.$root.querySelectorAll(`.${this.sectionClass}`);
+        if (!n.length) throw new ElementError({
             component: Accordion,
             identifier: `Sections (\`<div class="${this.sectionClass}">\`)`
         });
-        this.$sections = s, this.initControls(), this.initSectionHeaders(), this.updateShowAllButton(this.areAllSectionsOpen())
+        this.$sections = n, 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);
@@ -254,53 +277,53 @@ class Accordion extends GOVUKFrontendComponent {
     }
     initSectionHeaders() {
         this.$sections.forEach(((t, e) => {
-            const s = t.querySelector(`.${this.sectionHeaderClass}`);
-            if (!s) throw new ElementError({
+            const n = t.querySelector(`.${this.sectionHeaderClass}`);
+            if (!n) throw new ElementError({
                 component: Accordion,
                 identifier: `Section headers (\`<div class="${this.sectionHeaderClass}">\`)`
             });
-            this.constructHeaderMarkup(s, e), this.setExpanded(this.isExpanded(t), t), s.addEventListener("click", (() => this.onSectionToggle(t))), this.setInitialState(t)
+            this.constructHeaderMarkup(n, e), this.setExpanded(this.isExpanded(t), t), n.addEventListener("click", (() => this.onSectionToggle(t))), this.setInitialState(t)
         }))
     }
     constructHeaderMarkup(t, e) {
-        const s = t.querySelector(`.${this.sectionButtonClass}`),
-            n = t.querySelector(`.${this.sectionHeadingClass}`),
-            i = t.querySelector(`.${this.sectionSummaryClass}`);
-        if (!n) throw new ElementError({
+        const n = t.querySelector(`.${this.sectionButtonClass}`),
+            i = t.querySelector(`.${this.sectionHeadingClass}`),
+            s = t.querySelector(`.${this.sectionSummaryClass}`);
+        if (!i) throw new ElementError({
             component: Accordion,
             identifier: `Section heading (\`.${this.sectionHeadingClass}\`)`
         });
-        if (!s) throw new ElementError({
+        if (!n) throw new ElementError({
             component: Accordion,
             identifier: `Section button placeholder (\`<span class="${this.sectionButtonClass}">\`)`
         });
         const o = document.createElement("button");
         o.setAttribute("type", "button"), o.setAttribute("aria-controls", `${this.$root.id}-content-${e+1}`);
-        for (const d of Array.from(s.attributes)) "id" !== d.name && o.setAttribute(d.name, d.value);
+        for (const d of Array.from(n.attributes)) "id" !== d.name && o.setAttribute(d.name, d.value);
         const r = document.createElement("span");
-        r.classList.add(this.sectionHeadingTextClass), r.id = s.id;
+        r.classList.add(this.sectionHeadingTextClass), r.id = n.id;
         const a = document.createElement("span");
-        a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), Array.from(s.childNodes).forEach((t => a.appendChild(t)));
+        a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), Array.from(n.childNodes).forEach((t => a.appendChild(t)));
         const c = document.createElement("span");
         c.classList.add(this.sectionShowHideToggleClass), c.setAttribute("data-nosnippet", "");
         const l = document.createElement("span");
         l.classList.add(this.sectionShowHideToggleFocusClass), c.appendChild(l);
         const h = document.createElement("span"),
             u = document.createElement("span");
-        if (u.classList.add(this.upChevronIconClass), l.appendChild(u), h.classList.add(this.sectionShowHideTextClass), l.appendChild(h), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), i) {
+        if (u.classList.add(this.upChevronIconClass), l.appendChild(u), h.classList.add(this.sectionShowHideTextClass), l.appendChild(h), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), s) {
             const t = document.createElement("span"),
                 e = document.createElement("span");
             e.classList.add(this.sectionSummaryFocusClass), t.appendChild(e);
-            for (const s of Array.from(i.attributes)) t.setAttribute(s.name, s.value);
-            Array.from(i.childNodes).forEach((t => e.appendChild(t))), i.remove(), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
+            for (const n of Array.from(s.attributes)) t.setAttribute(n.name, n.value);
+            Array.from(s.childNodes).forEach((t => e.appendChild(t))), s.remove(), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
         }
-        o.appendChild(c), n.removeChild(s), n.appendChild(o)
+        o.appendChild(c), i.removeChild(n), i.appendChild(o)
     }
     onBeforeMatch(t) {
         const e = t.target;
         if (!(e instanceof Element)) return;
-        const s = e.closest(`.${this.sectionClass}`);
-        s && this.setExpanded(!0, s)
+        const n = e.closest(`.${this.sectionClass}`);
+        n && this.setExpanded(!0, n)
     }
     onSectionToggle(t) {
         const e = !this.isExpanded(t);
@@ -313,24 +336,24 @@ class Accordion extends GOVUKFrontendComponent {
         })), this.updateShowAllButton(t)
     }
     setExpanded(t, e) {
-        const s = e.querySelector(`.${this.upChevronIconClass}`),
-            n = e.querySelector(`.${this.sectionShowHideTextClass}`),
-            i = e.querySelector(`.${this.sectionButtonClass}`),
+        const n = e.querySelector(`.${this.upChevronIconClass}`),
+            i = e.querySelector(`.${this.sectionShowHideTextClass}`),
+            s = e.querySelector(`.${this.sectionButtonClass}`),
             o = e.querySelector(`.${this.sectionContentClass}`);
         if (!o) throw new ElementError({
             component: Accordion,
             identifier: `Section content (\`<div class="${this.sectionContentClass}">\`)`
         });
-        if (!s || !n || !i) return;
+        if (!n || !i || !s) return;
         const r = t ? this.i18n.t("hideSection") : this.i18n.t("showSection");
-        n.textContent = r, i.setAttribute("aria-expanded", `${t}`);
+        i.textContent = r, s.setAttribute("aria-expanded", `${t}`);
         const a = [],
             c = e.querySelector(`.${this.sectionHeadingTextClass}`);
         c && a.push(`${c.textContent}`.trim());
         const l = e.querySelector(`.${this.sectionSummaryClass}`);
         l && a.push(`${l.textContent}`.trim());
         const h = t ? this.i18n.t("hideSectionAriaLabel") : this.i18n.t("showSectionAriaLabel");
-        a.push(h), i.setAttribute("aria-label", a.join(" , ")), t ? (o.removeAttribute("hidden"), e.classList.add(this.sectionExpandedClass), s.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), e.classList.remove(this.sectionExpandedClass), s.classList.add(this.downChevronIconClass)), this.updateShowAllButton(this.areAllSectionsOpen())
+        a.push(h), s.setAttribute("aria-label", a.join(" , ")), t ? (o.removeAttribute("hidden"), e.classList.add(this.sectionExpandedClass), n.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), e.classList.remove(this.sectionExpandedClass), n.classList.add(this.downChevronIconClass)), this.updateShowAllButton(this.areAllSectionsOpen())
     }
     isExpanded(t) {
         return t.classList.contains(this.sectionExpandedClass)
@@ -347,18 +370,18 @@ class Accordion extends GOVUKFrontendComponent {
     }
     storeState(t, e) {
         if (!this.config.rememberExpanded) return;
-        const s = this.getIdentifier(t);
-        if (s) try {
-            window.sessionStorage.setItem(s, e.toString())
-        } catch (n) {}
+        const n = this.getIdentifier(t);
+        if (n) try {
+            window.sessionStorage.setItem(n, e.toString())
+        } catch (i) {}
     }
     setInitialState(t) {
         if (!this.config.rememberExpanded) return;
         const e = this.getIdentifier(t);
         if (e) try {
-            const s = window.sessionStorage.getItem(e);
-            null !== s && this.setExpanded("true" === s, t)
-        } catch (s) {}
+            const n = window.sessionStorage.getItem(e);
+            null !== n && this.setExpanded("true" === n, t)
+        } catch (n) {}
     }
     getButtonPunctuationEl() {
         const t = document.createElement("span");
@@ -387,7 +410,7 @@ Accordion.moduleName = "govuk-accordion", Accordion.defaults = Object.freeze({
 });
 class Button extends GOVUKFrontendComponent {
     constructor(t, e = {}) {
-        super(t), this.config = void 0, this.debounceFormSubmitTimer = null, this.config = mergeConfigs(Button.defaults, e, normaliseDataset(Button, this.$root.dataset)), this.$root.addEventListener("keydown", (t => this.handleKeyDown(t))), this.$root.addEventListener("click", (t => this.debounce(t)))
+        super(t), this.config = void 0, this.debounceFormSubmitTimer = null, this.config = new Config(Button, this.$root.dataset, e), this.$root.addEventListener("keydown", (t => this.handleKeyDown(t))), this.$root.addEventListener("click", (t => this.debounce(t)))
     }
     handleKeyDown(t) {
         const e = t.target;
@@ -401,8 +424,8 @@ class Button extends GOVUKFrontendComponent {
 }
 
 function closestAttributeValue(t, e) {
-    const s = t.closest(`[${e}]`);
-    return s ? s.getAttribute(e) : null
+    const n = t.closest(`[${e}]`);
+    return n ? n.getAttribute(e) : null
 }
 Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
     preventDoubleClick: !1
@@ -415,54 +438,50 @@ Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
 });
 class CharacterCount extends GOVUKFrontendComponent {
     constructor(t, e = {}) {
-        var s, n;
+        var n, i;
         super(t), this.$textarea = void 0, this.$visibleCountMessage = void 0, this.$screenReaderCountMessage = void 0, this.lastInputTimestamp = null, this.lastInputValue = "", this.valueChecker = null, this.config = void 0, this.i18n = void 0, this.maxLength = void 0;
-        const i = this.$root.querySelector(".govuk-js-character-count");
-        if (!(i instanceof HTMLTextAreaElement || i instanceof HTMLInputElement)) throw new ElementError({
+        const s = this.$root.querySelector(".govuk-js-character-count");
+        if (!(s instanceof HTMLTextAreaElement || s instanceof HTMLInputElement)) throw new ElementError({
             component: CharacterCount,
-            element: i,
+            element: s,
             expectedType: "HTMLTextareaElement or HTMLInputElement",
             identifier: "Form field (`.govuk-js-character-count`)"
         });
-        const o = normaliseDataset(CharacterCount, this.$root.dataset);
-        let r = {};
-        ("maxwords" in o || "maxlength" in o) && (r = {
-            maxlength: void 0,
-            maxwords: void 0
-        }), this.config = mergeConfigs(CharacterCount.defaults, e, r, o);
-        const a = function(t, e) {
-            const s = [];
-            for (const [n, i] of Object.entries(t)) {
-                const t = [];
-                if (Array.isArray(i)) {
+        this.config = new Config(CharacterCount, this.$root.dataset, e);
+        const o = function(t) {
+            const e = [],
+                n = t.component.schema;
+            for (const [i, s] of Object.entries(n)) {
+                const n = [];
+                if (Array.isArray(s)) {
                     for (const {
-                            required: s,
-                            errorMessage: n
+                            required: e,
+                            errorMessage: i
                         }
-                        of i) s.every((t => !!e[t])) || t.push(n);
-                    "anyOf" !== n || i.length - t.length >= 1 || s.push(...t)
+                        of s) e.every((e => t.configObject[e])) || n.push(i);
+                    "anyOf" !== i || s.length - n.length >= 1 || e.push(...n)
                 }
             }
-            return s
-        }(CharacterCount.schema, this.config);
-        if (a[0]) throw new ConfigError(formatErrorMessage(CharacterCount, a[0]));
+            return e
+        }(this.config);
+        if (o[0]) throw new ConfigError(formatErrorMessage(CharacterCount, o[0]));
         this.i18n = new I18n(this.config.i18n, {
             locale: closestAttributeValue(this.$root, "lang")
-        }), this.maxLength = null != (s = null != (n = this.config.maxwords) ? n : this.config.maxlength) ? s : 1 / 0, this.$textarea = i;
-        const c = `${this.$textarea.id}-info`,
-            l = document.getElementById(c);
-        if (!l) throw new ElementError({
+        }), this.maxLength = null != (n = null != (i = this.config.maxwords) ? i : this.config.maxlength) ? n : 1 / 0, this.$textarea = s;
+        const r = `${this.$textarea.id}-info`,
+            a = document.getElementById(r);
+        if (!a) throw new ElementError({
             component: CharacterCount,
-            element: l,
-            identifier: `Count message (\`id="${c}"\`)`
+            element: a,
+            identifier: `Count message (\`id="${r}"\`)`
         });
-        `${l.textContent}`.match(/^\s*$/) && (l.textContent = this.i18n.t("textareaDescription", {
+        `${a.textContent}`.match(/^\s*$/) && (a.textContent = this.i18n.t("textareaDescription", {
             count: this.maxLength
-        })), this.$textarea.insertAdjacentElement("afterend", l);
-        const h = document.createElement("div");
-        h.className = "govuk-character-count__sr-status govuk-visually-hidden", h.setAttribute("aria-live", "polite"), this.$screenReaderCountMessage = h, l.insertAdjacentElement("afterend", h);
-        const u = document.createElement("div");
-        u.className = l.className, u.classList.add("govuk-character-count__status"), u.setAttribute("aria-hidden", "true"), this.$visibleCountMessage = u, l.insertAdjacentElement("afterend", u), l.classList.add("govuk-visually-hidden"), this.$textarea.removeAttribute("maxlength"), this.bindChangeEvents(), window.addEventListener("pageshow", (() => this.updateCountMessage())), this.updateCountMessage()
+        })), this.$textarea.insertAdjacentElement("afterend", a);
+        const c = document.createElement("div");
+        c.className = "govuk-character-count__sr-status govuk-visually-hidden", c.setAttribute("aria-live", "polite"), this.$screenReaderCountMessage = c, a.insertAdjacentElement("afterend", c);
+        const l = document.createElement("div");
+        l.className = a.className, l.classList.add("govuk-character-count__status"), l.setAttribute("aria-hidden", "true"), this.$visibleCountMessage = l, a.insertAdjacentElement("afterend", l), a.classList.add("govuk-visually-hidden"), this.$textarea.removeAttribute("maxlength"), this.bindChangeEvents(), window.addEventListener("pageshow", (() => this.updateCountMessage())), this.updateCountMessage()
     }
     bindChangeEvents() {
         this.$textarea.addEventListener("keyup", (() => this.handleKeyUp())), this.$textarea.addEventListener("focus", (() => this.handleFocus())), this.$textarea.addEventListener("blur", (() => this.handleBlur()))
@@ -505,8 +524,8 @@ class CharacterCount extends GOVUKFrontendComponent {
     }
     formatCountMessage(t, e) {
         if (0 === t) return this.i18n.t(`${e}AtLimit`);
-        const s = t < 0 ? "OverLimit" : "UnderLimit";
-        return this.i18n.t(`${e}${s}`, {
+        const n = t < 0 ? "OverLimit" : "UnderLimit";
+        return this.i18n.t(`${e}${n}`, {
             count: Math.abs(t)
         })
     }
@@ -541,7 +560,13 @@ CharacterCount.moduleName = "govuk-character-count", CharacterCount.defaults = O
             other: ""
         }
     }
-}), CharacterCount.schema = Object.freeze({
+}), CharacterCount.configOverride = t => {
+    let e = {};
+    return ("maxwords" in t || "maxlength" in t) && (e = {
+        maxlength: void 0,
+        maxwords: void 0
+    }), e
+}, CharacterCount.schema = Object.freeze({
     properties: {
         i18n: {
             type: "object"
@@ -589,10 +614,10 @@ class Checkboxes extends GOVUKFrontendComponent {
     syncConditionalRevealWithInputState(t) {
         const e = t.getAttribute("aria-controls");
         if (!e) return;
-        const s = document.getElementById(e);
-        if (null != s && s.classList.contains("govuk-checkboxes__conditional")) {
+        const n = document.getElementById(e);
+        if (null != n && n.classList.contains("govuk-checkboxes__conditional")) {
             const e = t.checked;
-            t.setAttribute("aria-expanded", e.toString()), s.classList.toggle("govuk-checkboxes__conditional--hidden", !e)
+            t.setAttribute("aria-expanded", e.toString()), n.classList.toggle("govuk-checkboxes__conditional--hidden", !e)
         }
     }
     unCheckAllInputsExcept(t) {
@@ -625,25 +650,25 @@ class ErrorSummary extends GOVUKFrontendComponent {
         if (!(t instanceof HTMLAnchorElement)) return !1;
         const e = getFragmentFromUrl(t.href);
         if (!e) return !1;
-        const s = document.getElementById(e);
-        if (!s) return !1;
-        const n = this.getAssociatedLegendOrLabel(s);
-        return !!n && (n.scrollIntoView(), s.focus({
+        const n = document.getElementById(e);
+        if (!n) return !1;
+        const i = this.getAssociatedLegendOrLabel(n);
+        return !!i && (i.scrollIntoView(), n.focus({
             preventScroll: !0
         }), !0)
     }
     getAssociatedLegendOrLabel(t) {
         var e;
-        const s = t.closest("fieldset");
-        if (s) {
-            const e = s.getElementsByTagName("legend");
+        const n = t.closest("fieldset");
+        if (n) {
+            const e = n.getElementsByTagName("legend");
             if (e.length) {
-                const s = e[0];
-                if (t instanceof HTMLInputElement && ("checkbox" === t.type || "radio" === t.type)) return s;
-                const n = s.getBoundingClientRect().top,
-                    i = t.getBoundingClientRect();
-                if (i.height && window.innerHeight) {
-                    if (i.top + i.height - n < window.innerHeight / 2) return s
+                const n = e[0];
+                if (t instanceof HTMLInputElement && ("checkbox" === t.type || "radio" === t.type)) return n;
+                const i = n.getBoundingClientRect().top,
+                    s = t.getBoundingClientRect();
+                if (s.height && window.innerHeight) {
+                    if (s.top + s.height - i < window.innerHeight / 2) return n
                 }
             }
         }
@@ -662,16 +687,16 @@ ErrorSummary.moduleName = "govuk-error-summary", ErrorSummary.defaults = Object.
 class ExitThisPage extends GOVUKFrontendComponent {
     constructor(t, e = {}) {
         super(t), this.config = void 0, this.i18n = void 0, this.$button = void 0, this.$skiplinkButton = null, this.$updateSpan = null, this.$indicatorContainer = null, this.$overlay = null, this.keypressCounter = 0, this.lastKeyWasModified = !1, this.timeoutTime = 5e3, this.keypressTimeoutId = null, this.timeoutMessageId = null;
-        const s = this.$root.querySelector(".govuk-exit-this-page__button");
-        if (!(s instanceof HTMLAnchorElement)) throw new ElementError({
+        const n = this.$root.querySelector(".govuk-exit-this-page__button");
+        if (!(n instanceof HTMLAnchorElement)) throw new ElementError({
             component: ExitThisPage,
-            element: s,
+            element: n,
             expectedType: "HTMLAnchorElement",
             identifier: "Button (`.govuk-exit-this-page__button`)"
         });
-        this.config = mergeConfigs(ExitThisPage.defaults, e, normaliseDataset(ExitThisPage, this.$root.dataset)), this.i18n = new I18n(this.config.i18n), this.$button = s;
-        const n = document.querySelector(".govuk-js-exit-this-page-skiplink");
-        n instanceof HTMLAnchorElement && (this.$skiplinkButton = n), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
+        this.config = mergeConfigs(ExitThisPage.defaults, e, normaliseDataset(ExitThisPage, this.$root.dataset)), this.i18n = new I18n(this.config.i18n), this.$button = n;
+        const i = document.querySelector(".govuk-js-exit-this-page-skiplink");
+        i instanceof HTMLAnchorElement && (this.$skiplinkButton = i), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
     }
     initUpdateSpan() {
         this.$updateSpan = document.createElement("span"), this.$updateSpan.setAttribute("role", "status"), this.$updateSpan.className = "govuk-visually-hidden", this.$root.appendChild(this.$updateSpan)
@@ -737,18 +762,18 @@ class Header extends GOVUKFrontendComponent {
         super(t), this.$menuButton = void 0, this.$menu = void 0, this.menuIsOpen = !1, this.mql = null;
         const e = this.$root.querySelector(".govuk-js-header-toggle");
         if (!e) return this;
-        const s = e.getAttribute("aria-controls");
-        if (!s) throw new ElementError({
+        const n = e.getAttribute("aria-controls");
+        if (!n) throw new ElementError({
             component: Header,
             identifier: 'Navigation button (`<button class="govuk-js-header-toggle">`) attribute (`aria-controls`)'
         });
-        const n = document.getElementById(s);
-        if (!n) throw new ElementError({
+        const i = document.getElementById(n);
+        if (!i) throw new ElementError({
             component: Header,
-            element: n,
-            identifier: `Navigation (\`<ul id="${s}">\`)`
+            element: i,
+            identifier: `Navigation (\`<ul id="${n}">\`)`
         });
-        this.$menu = n, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = i, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("desktop");
@@ -783,27 +808,27 @@ NotificationBanner.moduleName = "govuk-notification-banner", NotificationBanner.
 class PasswordInput extends GOVUKFrontendComponent {
     constructor(t, e = {}) {
         super(t), this.config = void 0, this.i18n = void 0, this.$input = void 0, this.$showHideButton = void 0, this.$screenReaderStatusMessage = void 0;
-        const s = this.$root.querySelector(".govuk-js-password-input-input");
-        if (!(s instanceof HTMLInputElement)) throw new ElementError({
+        const n = this.$root.querySelector(".govuk-js-password-input-input");
+        if (!(n instanceof HTMLInputElement)) throw new ElementError({
             component: PasswordInput,
-            element: s,
+            element: n,
             expectedType: "HTMLInputElement",
             identifier: "Form field (`.govuk-js-password-input-input`)"
         });
-        if ("password" !== s.type) throw new ElementError("Password input: Form field (`.govuk-js-password-input-input`) must be of type `password`.");
-        const n = this.$root.querySelector(".govuk-js-password-input-toggle");
-        if (!(n instanceof HTMLButtonElement)) throw new ElementError({
+        if ("password" !== n.type) throw new ElementError("Password input: Form field (`.govuk-js-password-input-input`) must be of type `password`.");
+        const i = this.$root.querySelector(".govuk-js-password-input-toggle");
+        if (!(i instanceof HTMLButtonElement)) throw new ElementError({
             component: PasswordInput,
-            element: n,
+            element: i,
             expectedType: "HTMLButtonElement",
             identifier: "Button (`.govuk-js-password-input-toggle`)"
         });
-        if ("button" !== n.type) throw new ElementError("Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.");
-        this.$input = s, this.$showHideButton = n, this.config = mergeConfigs(PasswordInput.defaults, e, normaliseDataset(PasswordInput, this.$root.dataset)), this.i18n = new I18n(this.config.i18n, {
+        if ("button" !== i.type) throw new ElementError("Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.");
+        this.$input = n, this.$showHideButton = i, this.config = mergeConfigs(PasswordInput.defaults, e, normaliseDataset(PasswordInput, this.$root.dataset)), this.i18n = new I18n(this.config.i18n, {
             locale: closestAttributeValue(this.$root, "lang")
         }), this.$showHideButton.removeAttribute("hidden");
-        const i = document.createElement("div");
-        i.className = "govuk-password-input__sr-status govuk-visually-hidden", i.setAttribute("aria-live", "polite"), this.$screenReaderStatusMessage = i, this.$input.insertAdjacentElement("afterend", i), this.$showHideButton.addEventListener("click", this.toggle.bind(this)), this.$input.form && this.$input.form.addEventListener("submit", (() => this.hide())), window.addEventListener("pageshow", (t => {
+        const s = document.createElement("div");
+        s.className = "govuk-password-input__sr-status govuk-visually-hidden", s.setAttribute("aria-live", "polite"), this.$screenReaderStatusMessage = s, this.$input.insertAdjacentElement("afterend", s), this.$showHideButton.addEventListener("click", this.toggle.bind(this)), this.$input.form && this.$input.form.addEventListener("submit", (() => this.hide())), window.addEventListener("pageshow", (t => {
             t.persisted && "password" !== this.$input.type && this.hide()
         })), this.hide()
     }
@@ -820,9 +845,9 @@ class PasswordInput extends GOVUKFrontendComponent {
         if (t === this.$input.type) return;
         this.$input.setAttribute("type", t);
         const e = "password" === t,
-            s = e ? "show" : "hide",
-            n = e ? "passwordHidden" : "passwordShown";
-        this.$showHideButton.innerText = this.i18n.t(`${s}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${s}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${n}Announcement`)
+            n = e ? "show" : "hide",
+            i = e ? "passwordHidden" : "passwordShown";
+        this.$showHideButton.innerText = this.i18n.t(`${n}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${n}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${i}Announcement`)
     }
 }
 PasswordInput.moduleName = "govuk-password-input", PasswordInput.defaults = Object.freeze({
@@ -866,21 +891,21 @@ class Radios extends GOVUKFrontendComponent {
     syncConditionalRevealWithInputState(t) {
         const e = t.getAttribute("aria-controls");
         if (!e) return;
-        const s = document.getElementById(e);
-        if (null != s && s.classList.contains("govuk-radios__conditional")) {
+        const n = document.getElementById(e);
+        if (null != n && n.classList.contains("govuk-radios__conditional")) {
             const e = t.checked;
-            t.setAttribute("aria-expanded", e.toString()), s.classList.toggle("govuk-radios__conditional--hidden", !e)
+            t.setAttribute("aria-expanded", e.toString()), n.classList.toggle("govuk-radios__conditional--hidden", !e)
         }
     }
     handleClick(t) {
         const e = t.target;
         if (!(e instanceof HTMLInputElement) || "radio" !== e.type) return;
-        const s = document.querySelectorAll('input[type="radio"][aria-controls]'),
-            n = e.form,
-            i = e.name;
-        s.forEach((t => {
-            const e = t.form === n;
-            t.name === i && e && this.syncConditionalRevealWithInputState(t)
+        const n = document.querySelectorAll('input[type="radio"][aria-controls]'),
+            i = e.form,
+            s = e.name;
+        n.forEach((t => {
+            const e = t.form === i;
+            t.name === s && e && this.syncConditionalRevealWithInputState(t)
         }))
     }
 }
@@ -890,18 +915,18 @@ class ServiceNavigation extends GOVUKFrontendComponent {
         super(t), this.$menuButton = void 0, this.$menu = void 0, this.menuIsOpen = !1, this.mql = null;
         const e = this.$root.querySelector(".govuk-js-service-navigation-toggle");
         if (!e) return this;
-        const s = e.getAttribute("aria-controls");
-        if (!s) throw new ElementError({
+        const n = e.getAttribute("aria-controls");
+        if (!n) throw new ElementError({
             component: ServiceNavigation,
             identifier: 'Navigation button (`<button class="govuk-js-service-navigation-toggle">`) attribute (`aria-controls`)'
         });
-        const n = document.getElementById(s);
-        if (!n) throw new ElementError({
+        const i = document.getElementById(n);
+        if (!i) throw new ElementError({
             component: ServiceNavigation,
-            element: n,
-            identifier: `Navigation (\`<ul id="${s}">\`)`
+            element: i,
+            identifier: `Navigation (\`<ul id="${n}">\`)`
         });
-        this.$menu = n, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = i, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("tablet");
@@ -923,17 +948,17 @@ class SkipLink extends GOVUKFrontendComponent {
     constructor(t) {
         var e;
         super(t);
-        const s = this.$root.hash,
-            n = null != (e = this.$root.getAttribute("href")) ? e : "";
-        let i;
+        const n = this.$root.hash,
+            i = null != (e = this.$root.getAttribute("href")) ? e : "";
+        let s;
         try {
-            i = new window.URL(this.$root.href)
+            s = new window.URL(this.$root.href)
         } catch (a) {
-            throw new ElementError(`Skip link: Target link (\`href="${n}"\`) is invalid`)
+            throw new ElementError(`Skip link: Target link (\`href="${i}"\`) is invalid`)
         }
-        if (i.origin !== window.location.origin || i.pathname !== window.location.pathname) return;
-        const o = getFragmentFromUrl(s);
-        if (!o) throw new ElementError(`Skip link: Target link (\`href="${n}"\`) has no hash fragment`);
+        if (s.origin !== window.location.origin || s.pathname !== window.location.pathname) return;
+        const o = getFragmentFromUrl(n);
+        if (!o) throw new ElementError(`Skip link: Target link (\`href="${i}"\`) has no hash fragment`);
         const r = document.getElementById(o);
         if (!r) throw new ElementError({
             component: SkipLink,
@@ -960,17 +985,17 @@ class Tabs extends GOVUKFrontendComponent {
             identifier: 'Links (`<a class="govuk-tabs__tab">`)'
         });
         this.$tabs = e, this.boundTabClick = this.onTabClick.bind(this), this.boundTabKeydown = this.onTabKeydown.bind(this), this.boundOnHashChange = this.onHashChange.bind(this);
-        const s = this.$root.querySelector(".govuk-tabs__list"),
-            n = this.$root.querySelectorAll("li.govuk-tabs__list-item");
-        if (!s) throw new ElementError({
+        const n = this.$root.querySelector(".govuk-tabs__list"),
+            i = this.$root.querySelectorAll("li.govuk-tabs__list-item");
+        if (!n) throw new ElementError({
             component: Tabs,
             identifier: 'List (`<ul class="govuk-tabs__list">`)'
         });
-        if (!n.length) throw new ElementError({
+        if (!i.length) throw new ElementError({
             component: Tabs,
             identifier: 'List items (`<li class="govuk-tabs__list-item">`)'
         });
-        this.$tabList = s, this.$tabListItems = n, this.setupResponsiveChecks()
+        this.$tabList = n, this.$tabListItems = i, this.setupResponsiveChecks()
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("tablet");
@@ -1006,8 +1031,8 @@ class Tabs extends GOVUKFrontendComponent {
             e = this.getTab(t);
         if (!e) return;
         if (this.changingHash) return void(this.changingHash = !1);
-        const s = this.getCurrentTab();
-        s && (this.hideTab(s), this.showTab(e), e.focus())
+        const n = this.getCurrentTab();
+        n && (this.hideTab(n), this.showTab(e), e.focus())
     }
     hideTab(t) {
         this.unhighlightTab(t), this.hidePanel(t)
@@ -1022,8 +1047,8 @@ class Tabs extends GOVUKFrontendComponent {
         const e = getFragmentFromUrl(t.href);
         if (!e) return;
         t.setAttribute("id", `tab_${e}`), t.setAttribute("role", "tab"), t.setAttribute("aria-controls", e), t.setAttribute("aria-selected", "false"), t.setAttribute("tabindex", "-1");
-        const s = this.getPanel(t);
-        s && (s.setAttribute("role", "tabpanel"), s.setAttribute("aria-labelledby", t.id), s.classList.add(this.jsHiddenClass))
+        const n = this.getPanel(t);
+        n && (n.setAttribute("role", "tabpanel"), n.setAttribute("aria-labelledby", t.id), n.classList.add(this.jsHiddenClass))
     }
     unsetAttributes(t) {
         t.removeAttribute("id"), t.removeAttribute("role"), t.removeAttribute("aria-controls"), t.removeAttribute("aria-selected"), t.removeAttribute("tabindex");
@@ -1032,14 +1057,14 @@ class Tabs extends GOVUKFrontendComponent {
     }
     onTabClick(t) {
         const e = this.getCurrentTab(),
-            s = t.currentTarget;
-        e && s instanceof HTMLAnchorElement && (t.preventDefault(), this.hideTab(e), this.showTab(s), this.createHistoryEntry(s))
+            n = t.currentTarget;
+        e && n instanceof HTMLAnchorElement && (t.preventDefault(), this.hideTab(e), this.showTab(n), this.createHistoryEntry(n))
     }
     createHistoryEntry(t) {
         const e = this.getPanel(t);
         if (!e) return;
-        const s = e.id;
-        e.id = "", this.changingHash = !0, window.location.hash = s, e.id = s
+        const n = e.id;
+        e.id = "", this.changingHash = !0, window.location.hash = n, e.id = n
     }
     onTabKeydown(t) {
         switch (t.key) {
@@ -1057,16 +1082,16 @@ class Tabs extends GOVUKFrontendComponent {
         if (null == t || !t.parentElement) return;
         const e = t.parentElement.nextElementSibling;
         if (!e) return;
-        const s = e.querySelector("a.govuk-tabs__tab");
-        s && (this.hideTab(t), this.showTab(s), s.focus(), this.createHistoryEntry(s))
+        const n = e.querySelector("a.govuk-tabs__tab");
+        n && (this.hideTab(t), this.showTab(n), n.focus(), this.createHistoryEntry(n))
     }
     activatePreviousTab() {
         const t = this.getCurrentTab();
         if (null == t || !t.parentElement) return;
         const e = t.parentElement.previousElementSibling;
         if (!e) return;
-        const s = e.querySelector("a.govuk-tabs__tab");
-        s && (this.hideTab(t), this.showTab(s), s.focus(), this.createHistoryEntry(s))
+        const n = e.querySelector("a.govuk-tabs__tab");
+        n && (this.hideTab(t), this.showTab(n), n.focus(), this.createHistoryEntry(n))
     }
     getPanel(t) {
         const e = getFragmentFromUrl(t.href);
@@ -1096,7 +1121,7 @@ function initAll(t) {
     if (t = void 0 !== t ? t : {}, !isSupported()) return void(t.onError ? t.onError(new SupportError, {
         config: t
     }) : console.log(new SupportError));
-    const s = [
+    const n = [
             [Accordion, t.accordion],
             [Button, t.button],
             [CharacterCount, t.characterCount],
@@ -1111,32 +1136,32 @@ function initAll(t) {
             [SkipLink],
             [Tabs]
         ],
-        n = {
+        i = {
             scope: null != (e = t.scope) ? e : document,
             onError: t.onError
         };
-    s.forEach((([Component, t]) => {
-        createAll(Component, t, n)
+    n.forEach((([Component, t]) => {
+        createAll(Component, t, i)
     }))
 }
 
 function createAll(Component, t, e) {
-    let s, n = document;
-    var i;
-    "object" == typeof e && (n = null != (i = e.scope) ? i : n, s = e.onError);
-    "function" == typeof e && (s = e), e instanceof HTMLElement && (n = e);
-    const o = n.querySelectorAll(`[data-module="${Component.moduleName}"]`);
+    let n, i = document;
+    var s;
+    "object" == typeof e && (i = null != (s = e.scope) ? s : i, n = e.onError);
+    "function" == typeof e && (n = e), e instanceof HTMLElement && (i = e);
+    const o = i.querySelectorAll(`[data-module="${Component.moduleName}"]`);
     return isSupported() ? Array.from(o).map((e => {
         try {
             return void 0 !== t ? new Component(e, t) : new Component(e)
-        } catch (n) {
-            return s ? s(n, {
+        } catch (i) {
+            return n ? n(i, {
                 element: e,
                 component: Component,
                 config: t
-            }) : console.log(n), null
+            }) : console.log(i), null
         }
-    })).filter(Boolean) : (s ? s(new SupportError, {
+    })).filter(Boolean) : (n ? n(new SupportError, {
         component: Component,
         config: t
     }) : console.log(new SupportError), [])

Action run for 629d6ee

Copy link

Other changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.js b/packages/govuk-frontend/dist/govuk/all.bundle.js
index aad11d44a..1d03da0f3 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -133,8 +133,9 @@
     }
     return $scope.classList.contains('govuk-frontend-supported');
   }
-  function validateConfig(schema, config) {
+  function validateConfig(config) {
     const validationErrors = [];
+    const schema = config.component.schema;
     for (const [name, conditions] of Object.entries(schema)) {
       const errors = [];
       if (Array.isArray(conditions)) {
@@ -142,7 +143,7 @@
           required,
           errorMessage
         } of conditions) {
-          if (!required.every(key => !!config[key])) {
+          if (!required.every(key => config.configObject[key])) {
             errors.push(errorMessage);
           }
         }
@@ -190,19 +191,6 @@
    * @property {string} moduleName - Name of the component
    */
 
-  function normaliseDataset(Component, dataset) {
-    const out = {};
-    for (const [field, property] of Object.entries(Component.schema.properties)) {
-      if (field in dataset) {
-        out[field] = normaliseString(dataset[field], property);
-      }
-      if ((property == null ? void 0 : property.type) === 'object') {
-        out[field] = extractConfigByNamespace(Component, dataset, field);
-      }
-    }
-    return out;
-  }
-
   class GOVUKFrontendError extends Error {
     constructor(...args) {
       super(...args);
@@ -256,6 +244,76 @@
    * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
    */
 
+  function normaliseDataset(Component, dataset) {
+    const out = {};
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
+    }
+    return out;
+  }
+
+  class Config {
+    /**
+     * Merge configuration objects into a single config
+     *
+     * I think this makes sense to go in here rather then
+     * as utility function because it is used each time a
+     * configuration is created in the constructor of a component.
+     * So it would not be removed during tree-shaking.
+     *
+     * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+     * @returns {{[key:string]: unknown}} - merged configuration object
+     */
+    static 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] = Config.mergeConfigs(option, override);
+          } else {
+            formattedConfigObject[key] = override;
+          }
+        }
+      }
+      return formattedConfigObject;
+    }
+
+    /**
+     * @param {ComponentClass} component - Class of component using config
+     * @param {DOMStringMap} dataset - dataset of root component
+     * @param {...ConfigType} configObjects - Config objects to merge
+     */
+    constructor(component, dataset, ...configObjects) {
+      this.configObject = void 0;
+      this.component = void 0;
+      if (typeof component.defaults === 'undefined') {
+        throw new ConfigError('No defaults specified in component');
+      }
+      if (typeof component.schema === 'undefined') {
+        throw new ConfigError('No schema specified in component');
+      }
+      this.component = component;
+      const normalisedDataset = normaliseDataset(this.component, dataset);
+      this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+      const configObject = this.configObject;
+      return new Proxy(this, {
+        get(target, name, receiver) {
+          if (!Reflect.has(target, name)) {
+            return configObject[String(name)];
+          }
+          return Reflect.get(target, name, receiver);
+        }
+      });
+    }
+  }
+
   class GOVUKFrontendComponent {
     /**
      * Returns the root element of the component
@@ -550,7 +608,7 @@
       this.$showAllButton = null;
       this.$showAllIcon = null;
       this.$showAllText = null;
-      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, this.$root.dataset));
+      this.config = new Config(Accordion, this.$root.dataset, config);
       this.i18n = new I18n(this.config.i18n);
       const $sections = this.$root.querySelectorAll(`.${this.sectionClass}`);
       if (!$sections.length) {
@@ -861,7 +919,7 @@
       super($root);
       this.config = void 0;
       this.debounceFormSubmitTimer = null;
-      this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, this.$root.dataset));
+      this.config = new Config(Button, this.$root.dataset, config);
       this.$root.addEventListener('keydown', event => this.handleKeyDown(event));
       this.$root.addEventListener('click', event => this.debounce(event));
     }
@@ -955,16 +1013,8 @@
           identifier: 'Form field (`.govuk-js-character-count`)'
         });
       }
-      const datasetConfig = normaliseDataset(CharacterCount, this.$root.dataset);
-      let configOverrides = {};
-      if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
-        configOverrides = {
-          maxlength: undefined,
-          maxwords: undefined
-        };
-      }
-      this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
-      const errors = validateConfig(CharacterCount.schema, this.config);
+      this.config = new Config(CharacterCount, this.$root.dataset, config);
+      const errors = validateConfig(this.config);
       if (errors[0]) {
         throw new ConfigError(formatErrorMessage(CharacterCount, errors[0]));
       }
@@ -1179,6 +1229,22 @@
       }
     }
   });
+  /**
+   * Override configuration
+   *
+   * @param {CharacterCountConfig} config - config to override
+   * @returns {CharacterCountConfig} - overidden config
+   */
+  CharacterCount.configOverride = config => {
+    let configOverrides = {};
+    if ('maxwords' in config || 'maxlength' in config) {
+      configOverrides = {
+        maxlength: undefined,
+        maxwords: undefined
+      };
+    }
+    return configOverrides;
+  };
   CharacterCount.schema = Object.freeze({
     properties: {
       i18n: {
diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.mjs b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
index c72c0b957..940d63c49 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -127,8 +127,9 @@ function isSupported($scope = document.body) {
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
-function validateConfig(schema, config) {
+function validateConfig(config) {
   const validationErrors = [];
+  const schema = config.component.schema;
   for (const [name, conditions] of Object.entries(schema)) {
     const errors = [];
     if (Array.isArray(conditions)) {
@@ -136,7 +137,7 @@ function validateConfig(schema, config) {
         required,
         errorMessage
       } of conditions) {
-        if (!required.every(key => !!config[key])) {
+        if (!required.every(key => config.configObject[key])) {
           errors.push(errorMessage);
         }
       }
@@ -184,19 +185,6 @@ function formatErrorMessage(Component, message) {
  * @property {string} moduleName - Name of the component
  */
 
-function normaliseDataset(Component, dataset) {
-  const out = {};
-  for (const [field, property] of Object.entries(Component.schema.properties)) {
-    if (field in dataset) {
-      out[field] = normaliseString(dataset[field], property);
-    }
-    if ((property == null ? void 0 : property.type) === 'object') {
-      out[field] = extractConfigByNamespace(Component, dataset, field);
-    }
-  }
-  return out;
-}
-
 class GOVUKFrontendError extends Error {
   constructor(...args) {
     super(...args);
@@ -250,6 +238,76 @@ class InitError extends GOVUKFrontendError {
  * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
  */
 
+function normaliseDataset(Component, dataset) {
+  const out = {};
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
+  }
+  return out;
+}
+
+class Config {
+  /**
+   * Merge configuration objects into a single config
+   *
+   * I think this makes sense to go in here rather then
+   * as utility function because it is used each time a
+   * configuration is created in the constructor of a component.
+   * So it would not be removed during tree-shaking.
+   *
+   * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+   * @returns {{[key:string]: unknown}} - merged configuration object
+   */
+  static 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] = Config.mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
+      }
+    }
+    return formattedConfigObject;
+  }
+
+  /**
+   * @param {ComponentClass} component - Class of component using config
+   * @param {DOMStringMap} dataset - dataset of root component
+   * @param {...ConfigType} configObjects - Config objects to merge
+   */
+  constructor(component, dataset, ...configObjects) {
+    this.configObject = void 0;
+    this.component = void 0;
+    if (typeof component.defaults === 'undefined') {
+      throw new ConfigError('No defaults specified in component');
+    }
+    if (typeof component.schema === 'undefined') {
+      throw new ConfigError('No schema specified in component');
+    }
+    this.component = component;
+    const normalisedDataset = normaliseDataset(this.component, dataset);
+    this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+    const configObject = this.configObject;
+    return new Proxy(this, {
+      get(target, name, receiver) {
+        if (!Reflect.has(target, name)) {
+          return configObject[String(name)];
+        }
+        return Reflect.get(target, name, receiver);
+      }
+    });
+  }
+}
+
 class GOVUKFrontendComponent {
   /**
    * Returns the root element of the component
@@ -544,7 +602,7 @@ class Accordion extends GOVUKFrontendComponent {
     this.$showAllButton = null;
     this.$showAllIcon = null;
     this.$showAllText = null;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, this.$root.dataset));
+    this.config = new Config(Accordion, this.$root.dataset, config);
     this.i18n = new I18n(this.config.i18n);
     const $sections = this.$root.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
@@ -855,7 +913,7 @@ class Button extends GOVUKFrontendComponent {
     super($root);
     this.config = void 0;
     this.debounceFormSubmitTimer = null;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, this.$root.dataset));
+    this.config = new Config(Button, this.$root.dataset, config);
     this.$root.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$root.addEventListener('click', event => this.debounce(event));
   }
@@ -949,16 +1007,8 @@ class CharacterCount extends GOVUKFrontendComponent {
         identifier: 'Form field (`.govuk-js-character-count`)'
       });
     }
-    const datasetConfig = normaliseDataset(CharacterCount, this.$root.dataset);
-    let configOverrides = {};
-    if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
-      configOverrides = {
-        maxlength: undefined,
-        maxwords: undefined
-      };
-    }
-    this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
-    const errors = validateConfig(CharacterCount.schema, this.config);
+    this.config = new Config(CharacterCount, this.$root.dataset, config);
+    const errors = validateConfig(this.config);
     if (errors[0]) {
       throw new ConfigError(formatErrorMessage(CharacterCount, errors[0]));
     }
@@ -1173,6 +1223,22 @@ CharacterCount.defaults = Object.freeze({
     }
   }
 });
+/**
+ * Override configuration
+ *
+ * @param {CharacterCountConfig} config - config to override
+ * @returns {CharacterCountConfig} - overidden config
+ */
+CharacterCount.configOverride = config => {
+  let configOverrides = {};
+  if ('maxwords' in config || 'maxlength' in config) {
+    configOverrides = {
+      maxlength: undefined,
+      maxwords: undefined
+    };
+  }
+  return configOverrides;
+};
 CharacterCount.schema = Object.freeze({
   properties: {
     i18n: {
diff --git a/packages/govuk-frontend/dist/govuk/common/config.mjs b/packages/govuk-frontend/dist/govuk/common/config.mjs
new file mode 100644
index 000000000..28ce72a33
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/common/config.mjs
@@ -0,0 +1,63 @@
+import { ConfigError } from '../errors/index.mjs';
+import { isObject } from './index.mjs';
+import { normaliseDataset } from './normalise-dataset.mjs';
+
+class Config {
+  /**
+   * Merge configuration objects into a single config
+   *
+   * I think this makes sense to go in here rather then
+   * as utility function because it is used each time a
+   * configuration is created in the constructor of a component.
+   * So it would not be removed during tree-shaking.
+   *
+   * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+   * @returns {{[key:string]: unknown}} - merged configuration object
+   */
+  static 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] = Config.mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
+      }
+    }
+    return formattedConfigObject;
+  }
+
+  /**
+   * @param {ComponentClass} component - Class of component using config
+   * @param {DOMStringMap} dataset - dataset of root component
+   * @param {...ConfigType} configObjects - Config objects to merge
+   */
+  constructor(component, dataset, ...configObjects) {
+    this.configObject = void 0;
+    this.component = void 0;
+    if (typeof component.defaults === 'undefined') {
+      throw new ConfigError('No defaults specified in component');
+    }
+    if (typeof component.schema === 'undefined') {
+      throw new ConfigError('No schema specified in component');
+    }
+    this.component = component;
+    const normalisedDataset = normaliseDataset(this.component, dataset);
+    this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+    const configObject = this.configObject;
+    return new Proxy(this, {
+      get(target, name, receiver) {
+        if (!Reflect.has(target, name)) {
+          return configObject[String(name)];
+        }
+        return Reflect.get(target, name, receiver);
+      }
+    });
+  }
+}
+
+export { Config as default };
+//# sourceMappingURL=config.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/common/index.mjs b/packages/govuk-frontend/dist/govuk/common/index.mjs
index 8c09a78a4..0112e6b66 100644
--- a/packages/govuk-frontend/dist/govuk/common/index.mjs
+++ b/packages/govuk-frontend/dist/govuk/common/index.mjs
@@ -98,8 +98,9 @@ function isSupported($scope = document.body) {
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
-function validateConfig(schema, config) {
+function validateConfig(config) {
   const validationErrors = [];
+  const schema = config.component.schema;
   for (const [name, conditions] of Object.entries(schema)) {
     const errors = [];
     if (Array.isArray(conditions)) {
@@ -107,7 +108,7 @@ function validateConfig(schema, config) {
         required,
         errorMessage
       } of conditions) {
-        if (!required.every(key => !!config[key])) {
+        if (!required.every(key => config.configObject[key])) {
           errors.push(errorMessage);
         }
       }
@@ -155,5 +156,5 @@ function formatErrorMessage(Component, message) {
  * @property {string} moduleName - Name of the component
  */
 
-export { extractConfigByNamespace, formatErrorMessage, getBreakpoint, getFragmentFromUrl, isInitialised, isSupported, mergeConfigs, setFocus, validateConfig };
+export { extractConfigByNamespace, formatErrorMessage, getBreakpoint, getFragmentFromUrl, isInitialised, isObject, isSupported, mergeConfigs, setFocus, validateConfig };
 //# sourceMappingURL=index.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
index 925243064..034c9aff2 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
@@ -33,21 +33,6 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  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(Component, dataset, namespace) {
     const property = Component.schema.properties[namespace];
     if ((property == null ? void 0 : property.type) !== 'object') {
@@ -130,19 +115,6 @@
    * @property {string} moduleName - Name of the component
    */
 
-  function normaliseDataset(Component, dataset) {
-    const out = {};
-    for (const [field, property] of Object.entries(Component.schema.properties)) {
-      if (field in dataset) {
-        out[field] = normaliseString(dataset[field], property);
-      }
-      if ((property == null ? void 0 : property.type) === 'object') {
-        out[field] = extractConfigByNamespace(Component, dataset, field);
-      }
-    }
-    return out;
-  }
-
   class GOVUKFrontendError extends Error {
     constructor(...args) {
       super(...args);
@@ -161,6 +133,12 @@
       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 : '';
@@ -190,6 +168,76 @@
    * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
    */
 
+  function normaliseDataset(Component, dataset) {
+    const out = {};
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
+    }
+    return out;
+  }
+
+  class Config {
+    /**
+     * Merge configuration objects into a single config
+     *
+     * I think this makes sense to go in here rather then
+     * as utility function because it is used each time a
+     * configuration is created in the constructor of a component.
+     * So it would not be removed during tree-shaking.
+     *
+     * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+     * @returns {{[key:string]: unknown}} - merged configuration object
+     */
+    static 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] = Config.mergeConfigs(option, override);
+          } else {
+            formattedConfigObject[key] = override;
+          }
+        }
+      }
+      return formattedConfigObject;
+    }
+
+    /**
+     * @param {ComponentClass} component - Class of component using config
+     * @param {DOMStringMap} dataset - dataset of root component
+     * @param {...ConfigType} configObjects - Config objects to merge
+     */
+    constructor(component, dataset, ...configObjects) {
+      this.configObject = void 0;
+      this.component = void 0;
+      if (typeof component.defaults === 'undefined') {
+        throw new ConfigError('No defaults specified in component');
+      }
+      if (typeof component.schema === 'undefined') {
+        throw new ConfigError('No schema specified in component');
+      }
+      this.component = component;
+      const normalisedDataset = normaliseDataset(this.component, dataset);
+      this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+      const configObject = this.configObject;
+      return new Proxy(this, {
+        get(target, name, receiver) {
+          if (!Reflect.has(target, name)) {
+            return configObject[String(name)];
+          }
+          return Reflect.get(target, name, receiver);
+        }
+      });
+    }
+  }
+
   class GOVUKFrontendComponent {
     /**
      * Returns the root element of the component
@@ -484,7 +532,7 @@
       this.$showAllButton = null;
       this.$showAllIcon = null;
       this.$showAllText = null;
-      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, this.$root.dataset));
+      this.config = new Config(Accordion, this.$root.dataset, config);
       this.i18n = new I18n(this.config.i18n);
       const $sections = this.$root.querySelectorAll(`.${this.sectionClass}`);
       if (!$sections.length) {
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
index 4b6542f05..4cba93d42 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
@@ -27,21 +27,6 @@ function normaliseString(value, property) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-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(Component, dataset, namespace) {
   const property = Component.schema.properties[namespace];
   if ((property == null ? void 0 : property.type) !== 'object') {
@@ -124,19 +109,6 @@ function formatErrorMessage(Component, message) {
  * @property {string} moduleName - Name of the component
  */
 
-function normaliseDataset(Component, dataset) {
-  const out = {};
-  for (const [field, property] of Object.entries(Component.schema.properties)) {
-    if (field in dataset) {
-      out[field] = normaliseString(dataset[field], property);
-    }
-    if ((property == null ? void 0 : property.type) === 'object') {
-      out[field] = extractConfigByNamespace(Component, dataset, field);
-    }
-  }
-  return out;
-}
-
 class GOVUKFrontendError extends Error {
   constructor(...args) {
     super(...args);
@@ -155,6 +127,12 @@ class SupportError extends GOVUKFrontendError {
     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 : '';
@@ -184,6 +162,76 @@ class InitError extends GOVUKFrontendError {
  * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
  */
 
+function normaliseDataset(Component, dataset) {
+  const out = {};
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
+  }
+  return out;
+}
+
+class Config {
+  /**
+   * Merge configuration objects into a single config
+   *
+   * I think this makes sense to go in here rather then
+   * as utility function because it is used each time a
+   * configuration is created in the constructor of a component.
+   * So it would not be removed during tree-shaking.
+   *
+   * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+   * @returns {{[key:string]: unknown}} - merged configuration object
+   */
+  static 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] = Config.mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
+      }
+    }
+    return formattedConfigObject;
+  }
+
+  /**
+   * @param {ComponentClass} component - Class of component using config
+   * @param {DOMStringMap} dataset - dataset of root component
+   * @param {...ConfigType} configObjects - Config objects to merge
+   */
+  constructor(component, dataset, ...configObjects) {
+    this.configObject = void 0;
+    this.component = void 0;
+    if (typeof component.defaults === 'undefined') {
+      throw new ConfigError('No defaults specified in component');
+    }
+    if (typeof component.schema === 'undefined') {
+      throw new ConfigError('No schema specified in component');
+    }
+    this.component = component;
+    const normalisedDataset = normaliseDataset(this.component, dataset);
+    this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+    const configObject = this.configObject;
+    return new Proxy(this, {
+      get(target, name, receiver) {
+        if (!Reflect.has(target, name)) {
+          return configObject[String(name)];
+        }
+        return Reflect.get(target, name, receiver);
+      }
+    });
+  }
+}
+
 class GOVUKFrontendComponent {
   /**
    * Returns the root element of the component
@@ -478,7 +526,7 @@ class Accordion extends GOVUKFrontendComponent {
     this.$showAllButton = null;
     this.$showAllIcon = null;
     this.$showAllText = null;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, this.$root.dataset));
+    this.config = new Config(Accordion, this.$root.dataset, config);
     this.i18n = new I18n(this.config.i18n);
     const $sections = this.$root.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
index 528bdad14..ac1d3e220 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
@@ -1,5 +1,4 @@
-import { mergeConfigs } from '../../common/index.mjs';
-import { normaliseDataset } from '../../common/normalise-dataset.mjs';
+import Config from '../../common/config.mjs';
 import { ElementError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 import { I18n } from '../../i18n.mjs';
@@ -50,7 +49,7 @@ class Accordion extends GOVUKFrontendComponent {
     this.$showAllButton = null;
     this.$showAllIcon = null;
     this.$showAllText = null;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, this.$root.dataset));
+    this.config = new Config(Accordion, this.$root.dataset, config);
     this.i18n = new I18n(this.config.i18n);
     const $sections = this.$root.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
index e9ff03874..6792d8350 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
@@ -33,21 +33,6 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  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(Component, dataset, namespace) {
     const property = Component.schema.properties[namespace];
     if ((property == null ? void 0 : property.type) !== 'object') {
@@ -130,19 +115,6 @@
    * @property {string} moduleName - Name of the component
    */
 
-  function normaliseDataset(Component, dataset) {
-    const out = {};
-    for (const [field, property] of Object.entries(Component.schema.properties)) {
-      if (field in dataset) {
-        out[field] = normaliseString(dataset[field], property);
-      }
-      if ((property == null ? void 0 : property.type) === 'object') {
-        out[field] = extractConfigByNamespace(Component, dataset, field);
-      }
-    }
-    return out;
-  }
-
   class GOVUKFrontendError extends Error {
     constructor(...args) {
       super(...args);
@@ -161,6 +133,12 @@
       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 : '';
@@ -190,6 +168,76 @@
    * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
    */
 
+  function normaliseDataset(Component, dataset) {
+    const out = {};
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
+    }
+    return out;
+  }
+
+  class Config {
+    /**
+     * Merge configuration objects into a single config
+     *
+     * I think this makes sense to go in here rather then
+     * as utility function because it is used each time a
+     * configuration is created in the constructor of a component.
+     * So it would not be removed during tree-shaking.
+     *
+     * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+     * @returns {{[key:string]: unknown}} - merged configuration object
+     */
+    static 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] = Config.mergeConfigs(option, override);
+          } else {
+            formattedConfigObject[key] = override;
+          }
+        }
+      }
+      return formattedConfigObject;
+    }
+
+    /**
+     * @param {ComponentClass} component - Class of component using config
+     * @param {DOMStringMap} dataset - dataset of root component
+     * @param {...ConfigType} configObjects - Config objects to merge
+     */
+    constructor(component, dataset, ...configObjects) {
+      this.configObject = void 0;
+      this.component = void 0;
+      if (typeof component.defaults === 'undefined') {
+        throw new ConfigError('No defaults specified in component');
+      }
+      if (typeof component.schema === 'undefined') {
+        throw new ConfigError('No schema specified in component');
+      }
+      this.component = component;
+      const normalisedDataset = normaliseDataset(this.component, dataset);
+      this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+      const configObject = this.configObject;
+      return new Proxy(this, {
+        get(target, name, receiver) {
+          if (!Reflect.has(target, name)) {
+            return configObject[String(name)];
+          }
+          return Reflect.get(target, name, receiver);
+        }
+      });
+    }
+  }
+
   class GOVUKFrontendComponent {
     /**
      * Returns the root element of the component
@@ -261,7 +309,7 @@
       super($root);
       this.config = void 0;
       this.debounceFormSubmitTimer = null;
-      this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, this.$root.dataset));
+      this.config = new Config(Button, this.$root.dataset, config);
       this.$root.addEventListener('keydown', event => this.handleKeyDown(event));
       this.$root.addEventListener('click', event => this.debounce(event));
     }
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
index cd0977f2d..dd95fe406 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
@@ -27,21 +27,6 @@ function normaliseString(value, property) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-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(Component, dataset, namespace) {
   const property = Component.schema.properties[namespace];
   if ((property == null ? void 0 : property.type) !== 'object') {
@@ -124,19 +109,6 @@ function formatErrorMessage(Component, message) {
  * @property {string} moduleName - Name of the component
  */
 
-function normaliseDataset(Component, dataset) {
-  const out = {};
-  for (const [field, property] of Object.entries(Component.schema.properties)) {
-    if (field in dataset) {
-      out[field] = normaliseString(dataset[field], property);
-    }
-    if ((property == null ? void 0 : property.type) === 'object') {
-      out[field] = extractConfigByNamespace(Component, dataset, field);
-    }
-  }
-  return out;
-}
-
 class GOVUKFrontendError extends Error {
   constructor(...args) {
     super(...args);
@@ -155,6 +127,12 @@ class SupportError extends GOVUKFrontendError {
     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 : '';
@@ -184,6 +162,76 @@ class InitError extends GOVUKFrontendError {
  * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
  */
 
+function normaliseDataset(Component, dataset) {
+  const out = {};
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
+  }
+  return out;
+}
+
+class Config {
+  /**
+   * Merge configuration objects into a single config
+   *
+   * I think this makes sense to go in here rather then
+   * as utility function because it is used each time a
+   * configuration is created in the constructor of a component.
+   * So it would not be removed during tree-shaking.
+   *
+   * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+   * @returns {{[key:string]: unknown}} - merged configuration object
+   */
+  static 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] = Config.mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
+      }
+    }
+    return formattedConfigObject;
+  }
+
+  /**
+   * @param {ComponentClass} component - Class of component using config
+   * @param {DOMStringMap} dataset - dataset of root component
+   * @param {...ConfigType} configObjects - Config objects to merge
+   */
+  constructor(component, dataset, ...configObjects) {
+    this.configObject = void 0;
+    this.component = void 0;
+    if (typeof component.defaults === 'undefined') {
+      throw new ConfigError('No defaults specified in component');
+    }
+    if (typeof component.schema === 'undefined') {
+      throw new ConfigError('No schema specified in component');
+    }
+    this.component = component;
+    const normalisedDataset = normaliseDataset(this.component, dataset);
+    this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+    const configObject = this.configObject;
+    return new Proxy(this, {
+      get(target, name, receiver) {
+        if (!Reflect.has(target, name)) {
+          return configObject[String(name)];
+        }
+        return Reflect.get(target, name, receiver);
+      }
+    });
+  }
+}
+
 class GOVUKFrontendComponent {
   /**
    * Returns the root element of the component
@@ -255,7 +303,7 @@ class Button extends GOVUKFrontendComponent {
     super($root);
     this.config = void 0;
     this.debounceFormSubmitTimer = null;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, this.$root.dataset));
+    this.config = new Config(Button, this.$root.dataset, config);
     this.$root.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$root.addEventListener('click', event => this.debounce(event));
   }
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.mjs b/packages/govuk-frontend/dist/govuk/components/button/button.mjs
index b25a2f3a4..344311a69 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.mjs
@@ -1,5 +1,4 @@
-import { mergeConfigs } from '../../common/index.mjs';
-import { normaliseDataset } from '../../common/normalise-dataset.mjs';
+import Config from '../../common/config.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 
 const DEBOUNCE_TIMEOUT_IN_SECONDS = 1;
@@ -18,7 +17,7 @@ class Button extends GOVUKFrontendComponent {
     super($root);
     this.config = void 0;
     this.debounceFormSubmitTimer = null;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, this.$root.dataset));
+    this.config = new Config(Button, this.$root.dataset, config);
     this.$root.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$root.addEventListener('click', event => this.debounce(event));
   }
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
index a092f98ef..6ae188ef5 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
@@ -38,21 +38,6 @@
    * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
    */
 
-  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(Component, dataset, namespace) {
     const property = Component.schema.properties[namespace];
     if ((property == null ? void 0 : property.type) !== 'object') {
@@ -98,8 +83,9 @@
     }
     return $scope.classList.contains('govuk-frontend-supported');
   }
-  function validateConfig(schema, config) {
+  function validateConfig(config) {
     const validationErrors = [];
+    const schema = config.component.schema;
     for (const [name, conditions] of Object.entries(schema)) {
       const errors = [];
       if (Array.isArray(conditions)) {
@@ -107,7 +93,7 @@
           required,
           errorMessage
         } of conditions) {
-          if (!required.every(key => !!config[key])) {
+          if (!required.every(key => config.configObject[key])) {
             errors.push(errorMessage);
           }
         }
@@ -155,19 +141,6 @@
    * @property {string} moduleName - Name of the component
    */
 
-  function normaliseDataset(Component, dataset) {
-    const out = {};
-    for (const [field, property] of Object.entries(Component.schema.properties)) {
-      if (field in dataset) {
-        out[field] = normaliseString(dataset[field], property);
-      }
-      if ((property == null ? void 0 : property.type) === 'object') {
-        out[field] = extractConfigByNamespace(Component, dataset, field);
-      }
-    }
-    return out;
-  }
-
   class GOVUKFrontendError extends Error {
     constructor(...args) {
       super(...args);
@@ -221,6 +194,76 @@
    * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
    */
 
+  function normaliseDataset(Component, dataset) {
+    const out = {};
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
+    }
+    return out;
+  }
+
+  class Config {
+    /**
+     * Merge configuration objects into a single config
+     *
+     * I think this makes sense to go in here rather then
+     * as utility function because it is used each time a
+     * configuration is created in the constructor of a component.
+     * So it would not be removed during tree-shaking.
+     *
+     * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+     * @returns {{[key:string]: unknown}} - merged configuration object
+     */
+    static 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] = Config.mergeConfigs(option, override);
+          } else {
+            formattedConfigObject[key] = override;
+          }
+        }
+      }
+      return formattedConfigObject;
+    }
+
+    /**
+     * @param {ComponentClass} component - Class of component using config
+     * @param {DOMStringMap} dataset - dataset of root component
+     * @param {...ConfigType} configObjects - Config objects to merge
+     */
+    constructor(component, dataset, ...configObjects) {
+      this.configObject = void 0;
+      this.component = void 0;
+      if (typeof component.defaults === 'undefined') {
+        throw new ConfigError('No defaults specified in component');
+      }
+      if (typeof component.schema === 'undefined') {
+        throw new ConfigError('No schema specified in component');
+      }
+      this.component = component;
+      const normalisedDataset = normaliseDataset(this.component, dataset);
+      this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+      const configObject = this.configObject;
+      return new Proxy(this, {
+        get(target, name, receiver) {
+          if (!Reflect.has(target, name)) {
+            return configObject[String(name)];
+          }
+          return Reflect.get(target, name, receiver);
+        }
+      });
+    }
+  }
+
   class GOVUKFrontendComponent {
     /**
      * Returns the root element of the component
@@ -507,16 +550,8 @@
           identifier: 'Form field (`.govuk-js-character-count`)'
         });
       }
-      const datasetConfig = normaliseDataset(CharacterCount, this.$root.dataset);
-      let configOverrides = {};
-      if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
-        configOverrides = {
-          maxlength: undefined,
-          maxwords: undefined
-        };
-      }
-      this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
-      const errors = validateConfig(CharacterCount.schema, this.config);
+      this.config = new Config(CharacterCount, this.$root.dataset, config);
+      const errors = validateConfig(this.config);
       if (errors[0]) {
         throw new ConfigError(formatErrorMessage(CharacterCount, errors[0]));
       }
@@ -731,6 +766,22 @@
       }
     }
   });
+  /**
+   * Override configuration
+   *
+   * @param {CharacterCountConfig} config - config to override
+   * @returns {CharacterCountConfig} - overidden config
+   */
+  CharacterCount.configOverride = config => {
+    let configOverrides = {};
+    if ('maxwords' in config || 'maxlength' in config) {
+      configOverrides = {
+        maxlength: undefined,
+        maxwords: undefined
+      };
+    }
+    return configOverrides;
+  };
   CharacterCount.schema = Object.freeze({
     properties: {
       i18n: {
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
index d4e96618b..0c84e41ba 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
@@ -32,21 +32,6 @@ function normaliseString(value, property) {
  * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
  */
 
-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(Component, dataset, namespace) {
   const property = Component.schema.properties[namespace];
   if ((property == null ? void 0 : property.type) !== 'object') {
@@ -92,8 +77,9 @@ function isSupported($scope = document.body) {
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
-function validateConfig(schema, config) {
+function validateConfig(config) {
   const validationErrors = [];
+  const schema = config.component.schema;
   for (const [name, conditions] of Object.entries(schema)) {
     const errors = [];
     if (Array.isArray(conditions)) {
@@ -101,7 +87,7 @@ function validateConfig(schema, config) {
         required,
         errorMessage
       } of conditions) {
-        if (!required.every(key => !!config[key])) {
+        if (!required.every(key => config.configObject[key])) {
           errors.push(errorMessage);
         }
       }
@@ -149,19 +135,6 @@ function formatErrorMessage(Component, message) {
  * @property {string} moduleName - Name of the component
  */
 
-function normaliseDataset(Component, dataset) {
-  const out = {};
-  for (const [field, property] of Object.entries(Component.schema.properties)) {
-    if (field in dataset) {
-      out[field] = normaliseString(dataset[field], property);
-    }
-    if ((property == null ? void 0 : property.type) === 'object') {
-      out[field] = extractConfigByNamespace(Component, dataset, field);
-    }
-  }
-  return out;
-}
-
 class GOVUKFrontendError extends Error {
   constructor(...args) {
     super(...args);
@@ -215,6 +188,76 @@ class InitError extends GOVUKFrontendError {
  * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
  */
 
+function normaliseDataset(Component, dataset) {
+  const out = {};
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
+  }
+  return out;
+}
+
+class Config {
+  /**
+   * Merge configuration objects into a single config
+   *
+   * I think this makes sense to go in here rather then
+   * as utility function because it is used each time a
+   * configuration is created in the constructor of a component.
+   * So it would not be removed during tree-shaking.
+   *
+   * @param {...{[key:string]: unknown}} configObjects - configuration objects passed
+   * @returns {{[key:string]: unknown}} - merged configuration object
+   */
+  static 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] = Config.mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
+      }
+    }
+    return formattedConfigObject;
+  }
+
+  /**
+   * @param {ComponentClass} component - Class of component using config
+   * @param {DOMStringMap} dataset - dataset of root component
+   * @param {...ConfigType} configObjects - Config objects to merge
+   */
+  constructor(component, dataset, ...configObjects) {
+    this.configObject = void 0;
+    this.component = void 0;
+    if (typeof component.defaults === 'undefined') {
+      throw new ConfigError('No defaults specified in component');
+    }
+    if (typeof component.schema === 'undefined') {
+      throw new ConfigError('No schema specified in component');
+    }
+    this.component = component;
+    const normalisedDataset = normaliseDataset(this.component, dataset);
+    this.configObject = Config.mergeConfigs(this.component.defaults, ...configObjects, this.component.configOverride ? this.component.configOverride(normalisedDataset) : {}, normalisedDataset);
+    const configObject = this.configObject;
+    return new Proxy(this, {
+      get(target, name, receiver) {
+        if (!Reflect.has(target, name)) {
+          return configObject[String(name)];
+        }
+        return Reflect.get(target, name, receiver);
+      }
+    });
+  }
+}
+
 class GOVUKFrontendComponent {
   /**
    * Returns the root element of the component
@@ -501,16 +544,8 @@ class CharacterCount extends GOVUKFrontendComponent {
         identifier: 'Form field (`.govuk-js-character-count`)'
       });
     }
-    const datasetConfig = normaliseDataset(CharacterCount, this.$root.dataset);
-    let configOverrides = {};
-    if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
-      configOverrides = {
-        maxlength: undefined,
-        maxwords: undefined
-      };
-    }
-    this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
-    const errors = validateConfig(CharacterCount.schema, this.config);
+    this.config = new Config(CharacterCount, this.$root.dataset, config);
+    const errors = validateConfig(this.config);
     if (errors[0]) {
       throw new ConfigError(formatErrorMessage(CharacterCount, errors[0]));
     }
@@ -725,6 +760,22 @@ CharacterCount.defaults = Object.freeze({
     }
   }
 });
+/**
+ * Override configuration
+ *
+ * @param {CharacterCountConfig} config - config to override
+ * @returns {CharacterCountConfig} - overidden config
+ */
+CharacterCount.configOverride = config => {
+  let configOverrides = {};
+  if ('maxwords' in config || 'maxlength' in config) {
+    configOverrides = {
+      maxlength: undefined,
+      maxwords: undefined
+    };
+  }
+  return configOverrides;
+};
 CharacterCount.schema = Object.freeze({
   properties: {
     i18n: {
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
index 84cd54d37..9d6fca02e 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
@@ -1,6 +1,6 @@
 import { closestAttributeValue } from '../../common/closest-attribute-value.mjs';
-import { mergeConfigs, validateConfig, formatErrorMessage } from '../../common/index.mjs';
-import { normaliseDataset } from '../../common/normalise-dataset.mjs';
+import Config from '../../common/config.mjs';
+import { validateConfig, formatErrorMessage } from '../../common/index.mjs';
 import { ElementError, ConfigError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
 import { I18n } from '../../i18n.mjs';
@@ -43,16 +43,8 @@ class CharacterCount extends GOVUKFrontendComponent {
         identifier: 'Form field (`.govuk-js-character-count`)'
       });
     }
-    const datasetConfig = normaliseDataset(CharacterCount, this.$root.dataset);
-    let configOverrides = {};
-    if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
-      configOverrides = {
-        maxlength: undefined,
-        maxwords: undefined
-      };
-    }
-    this.config = mergeConfigs(CharacterCount.defaults, config, configOverrides, datasetConfig);
-    const errors = validateConfig(CharacterCount.schema, this.config);
+    this.config = new Config(CharacterCount, this.$root.dataset, config);
+    const errors = validateConfig(this.config);
     if (errors[0]) {
       throw new ConfigError(formatErrorMessage(CharacterCount, errors[0]));
     }
@@ -267,6 +259,22 @@ CharacterCount.defaults = Object.freeze({
     }
   }
 });
+/**
+ * Override configuration
+ *
+ * @param {CharacterCountConfig} config - config to override
+ * @returns {CharacterCountConfig} - overidden config
+ */
+CharacterCount.configOverride = config => {
+  let configOverrides = {};
+  if ('maxwords' in config || 'maxlength' in config) {
+    configOverrides = {
+      maxlength: undefined,
+      maxwords: undefined
+    };
+  }
+  return configOverrides;
+};
 CharacterCount.schema = Object.freeze({
   properties: {
     i18n: {

Action run for 629d6ee

@patrickpatrickpatrick patrickpatrickpatrick changed the title config if child defines Spike into Config class if defined by child class of GOVUKFrontendComponent Oct 23, 2024
Copy link
Member

@romaricpascal romaricpascal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping thoughts ahead of tomorrow's discussion. Looks like this approach could be a stepping stone before encapsulating how configs are merged and pulled from the elements dataset (spiked in #5426).

Wondering if having a class creating a Proxy is making typing more complex than we need it to be (with types like ConfigType<AccordionConfig> & AccordionConfig). Maybe a function that returns an object would make things simpler on the type side, especially as all the class does is storing data (there's no method updating that data or whose behaviour change based on the data).

Comment on lines +24 to +35
/**
* Merge configuration objects into a single config
*
* I think this makes sense to go in here rather then
* as utility function because it is used each time a
* configuration is created in the constructor of a component.
* So it would not be removed during tree-shaking.
*
* @param {...{[key:string]: unknown}} configObjects - configuration objects passed
* @returns {{[key:string]: unknown}} - merged configuration object
*/
static mergeConfigs(...configObjects) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: It's neat that it's been regrouped with the Config class rather than be in common/index.mjs. However, having it as a function in the module would allow its name to be mangled by minifiers (they leave methods alone as they may be called by outside code).

It'd likely need to be exported for unit tests, but we can document it as @internal to keep its use to ourselves.

@@ -276,7 +276,7 @@ function isArray(option) {
* @param {unknown} option - Option to check
* @returns {boolean} Whether the option is an object
*/
function isObject(option) {
export function isObject(option) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note Nice call leaving that one in index.mjs.

Comment on lines +71 to +73
if (typeof component.schema === 'undefined') {
throw new ConfigError('No schema specified in component')
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note It doesn't seem the class does anything of the schema beyond storing it. Thinking we can leave that out of the class for now (for simplicity), at least until we look at building validation inside the class).

Comment on lines +108 to +110
/**
* @typedef {{new (...args: any[]): any, moduleName: string, schema?: {[key:string]: unknown}, defaults?: {[key:string]: unknown} }} ComponentClass
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question Do you think we need to review the types around what makes a component class? Thinking we have that in a couple of places now (around createAll, around the GOVUKFrontendComponent class) and we may want to organise this a little bit into proper concepts: a constructor + having a moduleName + having configuration static properties (defaults at least).

* @returns {string[]} List of validation errors
*/
export function validateConfig(schema, config) {
export function validateConfig(config) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue I'd be keen to leave the validation completely outside the work on Config for now, until we figure whether we want to validate the configuration as part of our public API. That would avoid the Config object needing to store the schema.

// Check errors for each schema
for (const [name, conditions] of Object.entries(schema)) {
const errors = []

// Check errors for each schema condition
if (Array.isArray(conditions)) {
for (const { required, errorMessage } of conditions) {
if (!required.every((key) => !!config[key])) {
if (!required.every((key) => config.configObject[key])) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question Given the Config is a Proxy, what prevents us from accessing the key via config directly? Is it TypeScript?

/**
* @private
* @type {AccordionConfig}
* @type {Config<AccordionConfig> & AccordionConfig}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question I'm not understanding the combination of types on this one, sorry. What's requiring us to combine those?

Comment on lines +440 to +448
static configOverride = (config) => {
/** @type {CharacterCountConfig} */
let configOverrides = {}
if ('maxwords' in config || 'maxlength' in config) {
configOverrides = {
maxlength: undefined,
maxwords: undefined
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note I like the way you've solved the issue here. 🙌🏻

Thinking we should keep that feature @internal for now, though, as it's mostly due to the current set of options in this component. Then if people come asking for such feature for their components, we can look at offering it as part of the public API (and consider naming...). What are your thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion Static methods are natively supported in browsers we transpile to, while static fields will require extra code.

Suggested change
static configOverride = (config) => {
/** @type {CharacterCountConfig} */
let configOverrides = {}
if ('maxwords' in config || 'maxlength' in config) {
configOverrides = {
maxlength: undefined,
maxwords: undefined
}
}
static configOverride(config) {
/** @type {CharacterCountConfig} */
let configOverrides = {}
if ('maxwords' in config || 'maxlength' in config) {
configOverrides = {
maxlength: undefined,
maxwords: undefined
}
}

@@ -116,7 +126,8 @@ class Config {
**/

/**
* @typedef {{new (...args: any[]): any, moduleName: string, schema: {[key:string]: unknown}, defaults: {[key:string]: unknown} }} CompatibleClass
* @template {{[key:string]: unknown}} [ConfigType=ObjectNested]
* @typedef {{new (...args: any[]): any, moduleName: string, schema: import('./index.mjs').Schema, defaults: ConfigType, configOverride?: (config: ConfigType) => ConfigType }} CompatibleClass
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick Thinking CompatibleClass is a little generic here (it's also the case for the one used with createAll), can we maybe name it ConfigurableComponent or similar?

*
* @template {{[key:string]: unknown}} [ConfigType=ObjectNested]
*/
class Config {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick Playing around in VSCode, the name config leads to all .config.js file appearing when looking for the file with Ctrl+P. Configuration would avoid this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue We prefer named exports, which limit the risks of name being changed when imported, making it easier to debug.

Suggested change
class Config {
export class Config {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants