alphagov / govuk-frontend

GOV.UK Frontend contains the code you need to start building a user interface for government platforms and services.
https://frontend.design-system.service.gov.uk/
MIT License
1.18k stars 325 forks source link

[SPIKE] Explore progressively enhanced file upload component #5305

Open querkmachine opened 2 months ago

querkmachine commented 2 months ago

As part of post-audit investigations, a spike into layering a new UI onto the File upload component that should be a bit more friendly to assistive technologies. https://github.com/alphagov/govuk-design-system/issues/4031

Note: This hasn't actually been tested across browsers and ATs yet,

Changes

Thoughts

github-actions[bot] commented 2 months ago

:clipboard: Stats

File sizes

File Size
dist/govuk-frontend-development.min.css 119.03 KiB
dist/govuk-frontend-development.min.js 45.82 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 97 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 91.1 KiB
packages/govuk-frontend/dist/govuk/all.mjs 1.25 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend-component.mjs 1.74 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.css 119.02 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.js 45.81 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB
packages/govuk-frontend/dist/govuk/init.mjs 7.22 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 86.95 KiB 43.11 KiB
accordion.mjs 25.46 KiB 12.93 KiB
button.mjs 7.96 KiB 3.31 KiB
character-count.mjs 24.39 KiB 10.5 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
file-upload.mjs 17 KiB 8.19 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 980b1c6fbc1bc170d03614183e989902a07b9a4f

github-actions[bot] commented 2 months ago

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..1769da29b 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 i = t ? t.trim() : "";
+    let s, n = null == e ? void 0 : e.type;
+    switch (n || (["true", "false"].includes(i) && (n = "boolean"), i.length > 0 && isFinite(Number(i)) && (n = "number")), n) {
         case "boolean":
-            n = "true" === s;
+            s = "true" === i;
             break;
         case "number":
-            n = Number(s);
+            s = Number(i);
             break;
         default:
-            n = t
+            s = t
     }
-    return n
+    return s
 }

 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 i of t)
+        for (const t of Object.keys(i)) {
+            const s = e[t],
+                n = i[t];
+            isObject(s) && isObject(n) ? e[t] = mergeConfigs(s, n) : e[t] = n
         }
     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 i = Component.schema.properties[e];
+    if ("object" !== (null == i ? void 0 : i.type)) return;
+    const s = {
         [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 [n, o] of Object.entries(t)) {
+        let t = s;
+        const i = n.split(".");
+        for (const [s, r] of i.entries()) "object" == typeof t && (s < i.length - 1 ? (isObject(t[r]) || (t[r] = {}), t = t[r]) : n !== e && (t[r] = normaliseString(o)))
     }
-    return n[e]
+    return s[e]
 }

 function getFragmentFromUrl(t) {
@@ -54,20 +54,20 @@ function getBreakpoint(t) {
 }

 function setFocus(t, e = {}) {
-    var s;
-    const n = t.getAttribute("tabindex");
+    var i;
+    const s = t.getAttribute("tabindex");

     function onBlur() {
-        var s;
-        null == (s = e.onBlur) || s.call(t), n || t.removeAttribute("tabindex")
+        var i;
+        null == (i = e.onBlur) || i.call(t), s || t.removeAttribute("tabindex")
     }
-    n || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
+    s || 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 == (i = e.onBeforeFocus) || i.call(t), t.focus()
 }

 function isSupported(t = document.body) {
@@ -86,7 +86,7 @@ function formatErrorMessage(Component, 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));
+    for (const [i, s] of Object.entries(Component.schema.properties)) i in t && (e[i] = normaliseString(t[i], s)), "object" === (null == s ? void 0 : s.type) && (e[i] = extractConfigByNamespace(Component, t, i));
     return e
 }
 class GOVUKFrontendError extends Error {
@@ -110,12 +110,12 @@ class ElementError extends GOVUKFrontendError {
         let e = "string" == typeof t ? t : "";
         if ("object" == typeof t) {
             const {
-                component: s,
-                identifier: n,
-                element: i,
+                component: i,
+                identifier: s,
+                element: n,
                 expectedType: o
             } = t;
-            e = n, e += i ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(s, e)
+            e = s, e += n ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(i, e)
         }
         super(e), this.name = "ElementError"
     }
@@ -140,8 +140,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 i = e.moduleName;
+        this.$root.setAttribute(`data-${i}-init`, "")
     }
     checkInitialised() {
         const t = this.constructor,
@@ -157,31 +157,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 i;
+        this.translations = void 0, this.locale = void 0, this.translations = t, this.locale = null != (i = e.locale) ? i : 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 i = this.translations[t];
+        if ("number" == typeof(null == e ? void 0 : e.count) && "object" == typeof i) {
+            const s = i[this.getPluralSuffix(t, e.count)];
+            s && (i = s)
         }
-        if ("string" == typeof s) {
-            if (s.match(/%{(.\S+)}/)) {
+        if ("string" == typeof i) {
+            if (i.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(i, e)
             }
-            return s
+            return i
         }
         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 i = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
+        return t.replace(/%{(.\S+)}/g, (function(t, s) {
+            if (Object.prototype.hasOwnProperty.call(e, s)) {
+                const t = e[s];
+                return !1 === t || "number" != typeof t && "string" != typeof t ? "" : "number" == typeof t ? i ? i.format(t) : `${t}` : t
             }
             throw new Error(`i18n: no data found to replace ${t} placeholder in string`)
         }))
@@ -191,11 +191,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 i = this.translations[t],
+            s = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
+        if ("object" == typeof i) {
+            if (s in i) return s;
+            if ("other" in i) return console.warn(`i18n: Missing plural form ".${s}" for "${this.locale}" locale. Falling back to ".other".`), "other"
         }
         throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`)
     }
@@ -207,8 +207,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 i = I18n.pluralRulesMap[e];
+            if (i.includes(this.locale) || i.includes(t)) return e
         }
     }
 }
@@ -230,8 +230,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"
+            i = e % 10;
+        return 1 === i && 11 !== e ? "one" : i >= 2 && i <= 4 && !(e >= 12 && e <= 14) ? "few" : 0 === i || i >= 5 && i <= 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",
@@ -240,12 +240,12 @@ 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({
+        const i = this.$root.querySelectorAll(`.${this.sectionClass}`);
+        if (!i.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 = i, 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 +254,53 @@ class Accordion extends GOVUKFrontendComponent {
     }
     initSectionHeaders() {
         this.$sections.forEach(((t, e) => {
-            const s = t.querySelector(`.${this.sectionHeaderClass}`);
-            if (!s) throw new ElementError({
+            const i = t.querySelector(`.${this.sectionHeaderClass}`);
+            if (!i) 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(i, e), this.setExpanded(this.isExpanded(t), t), i.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 i = t.querySelector(`.${this.sectionButtonClass}`),
+            s = t.querySelector(`.${this.sectionHeadingClass}`),
+            n = t.querySelector(`.${this.sectionSummaryClass}`);
+        if (!s) throw new ElementError({
             component: Accordion,
             identifier: `Section heading (\`.${this.sectionHeadingClass}\`)`
         });
-        if (!s) throw new ElementError({
+        if (!i) 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(i.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 = i.id;
         const a = document.createElement("span");
-        a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), Array.from(s.childNodes).forEach((t => a.appendChild(t)));
-        const c = document.createElement("span");
-        c.classList.add(this.sectionShowHideToggleClass), c.setAttribute("data-nosnippet", "");
+        a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), Array.from(i.childNodes).forEach((t => a.appendChild(t)));
         const l = document.createElement("span");
-        l.classList.add(this.sectionShowHideToggleFocusClass), c.appendChild(l);
+        l.classList.add(this.sectionShowHideToggleClass), l.setAttribute("data-nosnippet", "");
+        const c = document.createElement("span");
+        c.classList.add(this.sectionShowHideToggleFocusClass), l.appendChild(c);
         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), c.appendChild(u), h.classList.add(this.sectionShowHideTextClass), c.appendChild(h), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), n) {
             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 i of Array.from(n.attributes)) t.setAttribute(i.name, i.value);
+            Array.from(n.childNodes).forEach((t => e.appendChild(t))), n.remove(), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
         }
-        o.appendChild(c), n.removeChild(s), n.appendChild(o)
+        o.appendChild(l), s.removeChild(i), s.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 i = e.closest(`.${this.sectionClass}`);
+        i && this.setExpanded(!0, i)
     }
     onSectionToggle(t) {
         const e = !this.isExpanded(t);
@@ -313,24 +313,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 i = e.querySelector(`.${this.upChevronIconClass}`),
+            s = e.querySelector(`.${this.sectionShowHideTextClass}`),
+            n = 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 (!i || !s || !n) return;
         const r = t ? this.i18n.t("hideSection") : this.i18n.t("showSection");
-        n.textContent = r, i.setAttribute("aria-expanded", `${t}`);
+        s.textContent = r, n.setAttribute("aria-expanded", `${t}`);
         const a = [],
-            c = e.querySelector(`.${this.sectionHeadingTextClass}`);
-        c && a.push(`${c.textContent}`.trim());
-        const l = e.querySelector(`.${this.sectionSummaryClass}`);
+            l = e.querySelector(`.${this.sectionHeadingTextClass}`);
         l && a.push(`${l.textContent}`.trim());
+        const c = e.querySelector(`.${this.sectionSummaryClass}`);
+        c && a.push(`${c.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), n.setAttribute("aria-label", a.join(" , ")), t ? (o.removeAttribute("hidden"), e.classList.add(this.sectionExpandedClass), i.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), e.classList.remove(this.sectionExpandedClass), i.classList.add(this.downChevronIconClass)), this.updateShowAllButton(this.areAllSectionsOpen())
     }
     isExpanded(t) {
         return t.classList.contains(this.sectionExpandedClass)
@@ -347,18 +347,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 i = this.getIdentifier(t);
+        if (i) try {
+            window.sessionStorage.setItem(i, e.toString())
+        } catch (s) {}
     }
     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 i = window.sessionStorage.getItem(e);
+            null !== i && this.setExpanded("true" === i, t)
+        } catch (i) {}
     }
     getButtonPunctuationEl() {
         const t = document.createElement("span");
@@ -401,8 +401,8 @@ class Button extends GOVUKFrontendComponent {
 }

 function closestAttributeValue(t, e) {
-    const s = t.closest(`[${e}]`);
-    return s ? s.getAttribute(e) : null
+    const i = t.closest(`[${e}]`);
+    return i ? i.getAttribute(e) : null
 }
 Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
     preventDoubleClick: !1
@@ -415,12 +415,12 @@ Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
 });
 class CharacterCount extends GOVUKFrontendComponent {
     constructor(t, e = {}) {
-        var s, n;
+        var i, s;
         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 n = this.$root.querySelector(".govuk-js-character-count");
+        if (!(n instanceof HTMLTextAreaElement || n instanceof HTMLInputElement)) throw new ElementError({
             component: CharacterCount,
-            element: i,
+            element: n,
             expectedType: "HTMLTextareaElement or HTMLInputElement",
             identifier: "Form field (`.govuk-js-character-count`)"
         });
@@ -431,38 +431,38 @@ class CharacterCount extends GOVUKFrontendComponent {
             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 i = [];
+            for (const [s, n] of Object.entries(t)) {
                 const t = [];
-                if (Array.isArray(i)) {
+                if (Array.isArray(n)) {
                     for (const {
-                            required: s,
-                            errorMessage: n
+                            required: i,
+                            errorMessage: s
                         }
-                        of i) s.every((t => !!e[t])) || t.push(n);
-                    "anyOf" !== n || i.length - t.length >= 1 || s.push(...t)
+                        of n) i.every((t => !!e[t])) || t.push(s);
+                    "anyOf" !== s || n.length - t.length >= 1 || i.push(...t)
                 }
             }
-            return s
+            return i
         }(CharacterCount.schema, this.config);
         if (a[0]) throw new ConfigError(formatErrorMessage(CharacterCount, a[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 != (i = null != (s = this.config.maxwords) ? s : this.config.maxlength) ? i : 1 / 0, this.$textarea = n;
+        const l = `${this.$textarea.id}-info`,
+            c = document.getElementById(l);
+        if (!c) throw new ElementError({
             component: CharacterCount,
-            element: l,
-            identifier: `Count message (\`id="${c}"\`)`
+            element: c,
+            identifier: `Count message (\`id="${l}"\`)`
         });
-        `${l.textContent}`.match(/^\s*$/) && (l.textContent = this.i18n.t("textareaDescription", {
+        `${c.textContent}`.match(/^\s*$/) && (c.textContent = this.i18n.t("textareaDescription", {
             count: this.maxLength
-        })), this.$textarea.insertAdjacentElement("afterend", l);
+        })), this.$textarea.insertAdjacentElement("afterend", c);
         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);
+        h.className = "govuk-character-count__sr-status govuk-visually-hidden", h.setAttribute("aria-live", "polite"), this.$screenReaderCountMessage = h, c.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()
+        u.className = c.className, u.classList.add("govuk-character-count__status"), u.setAttribute("aria-hidden", "true"), this.$visibleCountMessage = u, c.insertAdjacentElement("afterend", u), c.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 +505,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 i = t < 0 ? "OverLimit" : "UnderLimit";
+        return this.i18n.t(`${e}${i}`, {
             count: Math.abs(t)
         })
     }
@@ -589,10 +589,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 i = document.getElementById(e);
+        if (null != i && i.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()), i.classList.toggle("govuk-checkboxes__conditional--hidden", !e)
         }
     }
     unCheckAllInputsExcept(t) {
@@ -625,25 +625,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 i = document.getElementById(e);
+        if (!i) return !1;
+        const s = this.getAssociatedLegendOrLabel(i);
+        return !!s && (s.scrollIntoView(), i.focus({
             preventScroll: !0
         }), !0)
     }
     getAssociatedLegendOrLabel(t) {
         var e;
-        const s = t.closest("fieldset");
-        if (s) {
-            const e = s.getElementsByTagName("legend");
+        const i = t.closest("fieldset");
+        if (i) {
+            const e = i.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 i = e[0];
+                if (t instanceof HTMLInputElement && ("checkbox" === t.type || "radio" === t.type)) return i;
+                const s = i.getBoundingClientRect().top,
+                    n = t.getBoundingClientRect();
+                if (n.height && window.innerHeight) {
+                    if (n.top + n.height - s < window.innerHeight / 2) return i
                 }
             }
         }
@@ -662,16 +662,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 i = this.$root.querySelector(".govuk-exit-this-page__button");
+        if (!(i instanceof HTMLAnchorElement)) throw new ElementError({
             component: ExitThisPage,
-            element: s,
+            element: i,
             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 = i;
+        const s = document.querySelector(".govuk-js-exit-this-page-skiplink");
+        s instanceof HTMLAnchorElement && (this.$skiplinkButton = s), 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)
@@ -732,23 +732,84 @@ ExitThisPage.moduleName = "govuk-exit-this-page", ExitThisPage.defaults = Object
         }
     }
 });
+class FileUpload extends GOVUKFrontendComponent {
+    constructor(t, e = {}) {
+        if (super(t), this.$wrapper = void 0, this.$button = void 0, this.$status = void 0, this.config = void 0, this.i18n = void 0, !(this.$root instanceof HTMLInputElement)) return;
+        if ("file" !== this.$root.type) throw new ElementError("File upload: Form field must be an input of type `file`.");
+        if (this.config = mergeConfigs(FileUpload.defaults, e, normaliseDataset(FileUpload, this.$root.dataset)), this.i18n = new I18n(this.config.i18n, {
+                locale: closestAttributeValue(this.$root, "lang")
+            }), this.$label = document.querySelector(`[for="${this.$root.id}"]`), !this.$label) throw new ElementError({
+            component: FileUpload,
+            identifier: "No label"
+        });
+        const i = document.createElement("div");
+        i.className = "govuk-file-upload-wrapper";
+        const s = document.createElement("button");
+        s.className = "govuk-button govuk-button--secondary govuk-file-upload__button", s.type = "button", s.innerText = this.i18n.t("selectFilesButton"), s.addEventListener("click", this.onClick.bind(this));
+        const n = document.createElement("span");
+        n.className = "govuk-body govuk-file-upload__status", n.innerText = this.i18n.t("filesSelectedDefault"), n.setAttribute("role", "status"), i.insertAdjacentElement("beforeend", s), i.insertAdjacentElement("beforeend", n), this.$root.insertAdjacentElement("afterend", i), i.insertAdjacentElement("afterbegin", this.$root), this.$wrapper = i, this.$button = s, this.$status = n, this.$root.setAttribute("tabindex", "-1"), this.updateDisabledState(), this.observeDisabledState(), this.$root.addEventListener("change", this.onChange.bind(this)), this.$wrapper.addEventListener("drop", this.onDragLeaveOrDrop.bind(this)), document.addEventListener("dragenter", this.onDragEnter.bind(this)), document.addEventListener("dragleave", this.onDragLeaveOrDrop.bind(this))
+    }
+    onChange() {
+        if (!("files" in this.$root)) return;
+        if (!this.$root.files) return;
+        const t = this.$root.files.length;
+        this.$status && this.i18n && (this.$status.innerText = 0 === t ? this.i18n.t("filesSelectedDefault") : 1 === t ? this.$root.files[0].name : this.i18n.t("filesSelected", {
+            count: t
+        }))
+    }
+    onClick() {
+        this.$label instanceof HTMLElement && this.$label.click()
+    }
+    onDragEnter(t) {
+        console.log(t), this.$wrapper.classList.add("govuk-file-upload-wrapper--show-dropzone")
+    }
+    onDragLeaveOrDrop() {
+        this.$wrapper.classList.remove("govuk-file-upload-wrapper--show-dropzone")
+    }
+    observeDisabledState() {
+        new MutationObserver((t => {
+            for (const e of t) console.log("mutation", e), "attributes" === e.type && "disabled" === e.attributeName && this.updateDisabledState()
+        })).observe(this.$root, {
+            attributes: !0
+        })
+    }
+    updateDisabledState() {
+        this.$root instanceof HTMLInputElement && this.$button instanceof HTMLButtonElement && (this.$button.disabled = this.$root.disabled)
+    }
+}
+FileUpload.moduleName = "govuk-file-upload", FileUpload.defaults = Object.freeze({
+    i18n: {
+        selectFilesButton: "Choose file",
+        filesSelectedDefault: "No file chosen",
+        filesSelected: {
+            one: "%{count} file chosen",
+            other: "%{count} files chosen"
+        }
+    }
+}), FileUpload.schema = Object.freeze({
+    properties: {
+        i18n: {
+            type: "object"
+        }
+    }
+});
 class Header extends GOVUKFrontendComponent {
     constructor(t) {
         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 i = e.getAttribute("aria-controls");
+        if (!i) 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 s = document.getElementById(i);
+        if (!s) throw new ElementError({
             component: Header,
-            element: n,
-            identifier: `Navigation (\`<ul id="${s}">\`)`
+            element: s,
+            identifier: `Navigation (\`<ul id="${i}">\`)`
         });
-        this.$menu = n, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = s, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("desktop");
@@ -783,27 +844,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 i = this.$root.querySelector(".govuk-js-password-input-input");
+        if (!(i instanceof HTMLInputElement)) throw new ElementError({
             component: PasswordInput,
-            element: s,
+            element: i,
             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" !== i.type) throw new ElementError("Password input: Form field (`.govuk-js-password-input-input`) must be of type `password`.");
+        const s = this.$root.querySelector(".govuk-js-password-input-toggle");
+        if (!(s instanceof HTMLButtonElement)) throw new ElementError({
             component: PasswordInput,
-            element: n,
+            element: s,
             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" !== s.type) throw new ElementError("Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.");
+        this.$input = i, this.$showHideButton = s, 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 n = document.createElement("div");
+        n.className = "govuk-password-input__sr-status govuk-visually-hidden", n.setAttribute("aria-live", "polite"), this.$screenReaderStatusMessage = n, this.$input.insertAdjacentElement("afterend", n), 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 +881,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`)
+            i = e ? "show" : "hide",
+            s = e ? "passwordHidden" : "passwordShown";
+        this.$showHideButton.innerText = this.i18n.t(`${i}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${i}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${s}Announcement`)
     }
 }
 PasswordInput.moduleName = "govuk-password-input", PasswordInput.defaults = Object.freeze({
@@ -866,21 +927,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 i = document.getElementById(e);
+        if (null != i && i.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()), i.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 i = document.querySelectorAll('input[type="radio"][aria-controls]'),
+            s = e.form,
+            n = e.name;
+        i.forEach((t => {
+            const e = t.form === s;
+            t.name === n && e && this.syncConditionalRevealWithInputState(t)
         }))
     }
 }
@@ -890,18 +951,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 i = e.getAttribute("aria-controls");
+        if (!i) 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 s = document.getElementById(i);
+        if (!s) throw new ElementError({
             component: ServiceNavigation,
-            element: n,
-            identifier: `Navigation (\`<ul id="${s}">\`)`
+            element: s,
+            identifier: `Navigation (\`<ul id="${i}">\`)`
         });
-        this.$menu = n, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = s, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("tablet");
@@ -923,17 +984,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 i = this.$root.hash,
+            s = null != (e = this.$root.getAttribute("href")) ? e : "";
+        let n;
         try {
-            i = new window.URL(this.$root.href)
+            n = 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="${s}"\`) 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 (n.origin !== window.location.origin || n.pathname !== window.location.pathname) return;
+        const o = getFragmentFromUrl(i);
+        if (!o) throw new ElementError(`Skip link: Target link (\`href="${s}"\`) has no hash fragment`);
         const r = document.getElementById(o);
         if (!r) throw new ElementError({
             component: SkipLink,
@@ -960,17 +1021,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 i = this.$root.querySelector(".govuk-tabs__list"),
+            s = this.$root.querySelectorAll("li.govuk-tabs__list-item");
+        if (!i) throw new ElementError({
             component: Tabs,
             identifier: 'List (`<ul class="govuk-tabs__list">`)'
         });
-        if (!n.length) throw new ElementError({
+        if (!s.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 = i, this.$tabListItems = s, this.setupResponsiveChecks()
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("tablet");
@@ -1006,8 +1067,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 i = this.getCurrentTab();
+        i && (this.hideTab(i), this.showTab(e), e.focus())
     }
     hideTab(t) {
         this.unhighlightTab(t), this.hidePanel(t)
@@ -1022,8 +1083,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 i = this.getPanel(t);
+        i && (i.setAttribute("role", "tabpanel"), i.setAttribute("aria-labelledby", t.id), i.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 +1093,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))
+            i = t.currentTarget;
+        e && i instanceof HTMLAnchorElement && (t.preventDefault(), this.hideTab(e), this.showTab(i), this.createHistoryEntry(i))
     }
     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 i = e.id;
+        e.id = "", this.changingHash = !0, window.location.hash = i, e.id = i
     }
     onTabKeydown(t) {
         switch (t.key) {
@@ -1057,16 +1118,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 i = e.querySelector("a.govuk-tabs__tab");
+        i && (this.hideTab(t), this.showTab(i), i.focus(), this.createHistoryEntry(i))
     }
     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 i = e.querySelector("a.govuk-tabs__tab");
+        i && (this.hideTab(t), this.showTab(i), i.focus(), this.createHistoryEntry(i))
     }
     getPanel(t) {
         const e = getFragmentFromUrl(t.href);
@@ -1096,13 +1157,14 @@ 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 i = [
             [Accordion, t.accordion],
             [Button, t.button],
             [CharacterCount, t.characterCount],
             [Checkboxes],
             [ErrorSummary, t.errorSummary],
             [ExitThisPage, t.exitThisPage],
+            [FileUpload, t.fileUpload],
             [Header],
             [NotificationBanner, t.notificationBanner],
             [PasswordInput, t.passwordInput],
@@ -1111,32 +1173,32 @@ function initAll(t) {
             [SkipLink],
             [Tabs]
         ],
-        n = {
+        s = {
             scope: null != (e = t.scope) ? e : document,
             onError: t.onError
         };
-    s.forEach((([Component, t]) => {
-        createAll(Component, t, n)
+    i.forEach((([Component, t]) => {
+        createAll(Component, t, s)
     }))
 }

 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 i, s = document;
+    var n;
+    "object" == typeof e && (s = null != (n = e.scope) ? n : s, i = e.onError);
+    "function" == typeof e && (i = e), e instanceof HTMLElement && (s = e);
+    const o = s.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 (s) {
+            return i ? i(s, {
                 element: e,
                 component: Component,
                 config: t
-            }) : console.log(n), null
+            }) : console.log(s), null
         }
-    })).filter(Boolean) : (s ? s(new SupportError, {
+    })).filter(Boolean) : (i ? i(new SupportError, {
         component: Component,
         config: t
     }) : console.log(new SupportError), [])
@@ -1150,6 +1212,7 @@ export {
     GOVUKFrontendComponent as Component,
     ErrorSummary,
     ExitThisPage,
+    FileUpload,
     Header,
     NotificationBanner,
     PasswordInput,

Action run for 980b1c6fbc1bc170d03614183e989902a07b9a4f

github-actions[bot] commented 2 months ago

Stylesheets changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
index 647e22444..f1b48d569 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
@@ -3384,6 +3384,47 @@ screen and (forced-colors:active) {
     cursor: not-allowed
 }

+.govuk-file-upload-wrapper {
+    display: inline-flex;
+    align-items: baseline;
+    position: relative
+}
+
+.govuk-file-upload-wrapper--show-dropzone {
+    margin: -12px;
+    padding: 10px;
+    border: 2px dashed #0b0c0c;
+    background-color: #fff
+}
+
+.govuk-file-upload-wrapper--show-dropzone .govuk-file-upload__button,
+.govuk-file-upload-wrapper--show-dropzone .govuk-file-upload__status {
+    pointer-events: none
+}
+
+.govuk-file-upload-wrapper .govuk-file-upload {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    margin: 0;
+    padding: 0;
+    opacity: 0
+}
+
+.govuk-file-upload__button {
+    width: auto;
+    margin-bottom: 0;
+    flex-grow: 0;
+    flex-shrink: 0
+}
+
+.govuk-file-upload__status {
+    margin-bottom: 0;
+    margin-left: 10px
+}
+
 .govuk-footer {
     font-family: GDS Transport, arial, sans-serif;
     -webkit-font-smoothing: antialiased;

Action run for 980b1c6fbc1bc170d03614183e989902a07b9a4f

github-actions[bot] commented 2 months ago

Rendered HTML changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-direct-media-capture.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-direct-media-capture.html
new file mode 100644
index 000000000..0547b9801
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-direct-media-capture.html
@@ -0,0 +1,6 @@
+<div class="govuk-form-group">
+  <label class="govuk-label" for="file-upload-1">
+    Upload a file
+  </label>
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload" capture="user">
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-image-files-only.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-image-files-only.html
new file mode 100644
index 000000000..5c215788c
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-image-files-only.html
@@ -0,0 +1,6 @@
+<div class="govuk-form-group">
+  <label class="govuk-label" for="file-upload-1">
+    Upload a file
+  </label>
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload" accept="image/*">
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-value.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-multiple-files.html
similarity index 19%
rename from packages/govuk-frontend/dist/govuk/components/file-upload/template-with-value.html
rename to packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-multiple-files.html
index 68d350f46..3d12d7825 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-value.html
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-multiple-files.html
@@ -1,6 +1,6 @@
 <div class="govuk-form-group">
-  <label class="govuk-label" for="file-upload-4">
-    Upload a photo
+  <label class="govuk-label" for="file-upload-1">
+    Upload a file
   </label>
-  <input class="govuk-file-upload" id="file-upload-4" name="file-upload-4" type="file" value="C:&#92;fakepath&#92;myphoto.jpg">
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload" multiple>
 </div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-default.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-default.html
index 61ba3dedf..9ec77fcf2 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/template-default.html
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-default.html
@@ -2,5 +2,5 @@
   <label class="govuk-label" for="file-upload-1">
     Upload a file
   </label>
-  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file">
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload">
 </div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-disabled.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-disabled.html
new file mode 100644
index 000000000..ec0873804
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-disabled.html
@@ -0,0 +1,6 @@
+<div class="govuk-form-group">
+  <label class="govuk-label" for="file-upload-1">
+    Upload a file
+  </label>
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload" disabled>
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-translated.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-translated.html
new file mode 100644
index 000000000..a39fab0a5
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-translated.html
@@ -0,0 +1,6 @@
+<div class="govuk-form-group">
+  <label class="govuk-label" for="file-upload-1">
+    Llwythwch ffeil i fyny
+  </label>
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload" multiple data-i18n.select-files-button="Dewiswch ffeil" data-i18n.files-selected-default="Dim ffeiliau wedi&#39;u dewis" data-i18n.files-selected.other="%{count} ffeil wedi&#39;u dewis" data-i18n.files-selected.one="%{count} ffeil wedi&#39;i dewis">
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-error-message-and-hint.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-error-message-and-hint.html
index c3a5b6e63..545e1be0a 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-error-message-and-hint.html
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-error-message-and-hint.html
@@ -8,5 +8,5 @@
   <p id="file-upload-3-error" class="govuk-error-message">
     <span class="govuk-visually-hidden">Error:</span> Error message goes here
   </p>
-  <input class="govuk-file-upload govuk-file-upload--error" id="file-upload-3" name="file-upload-3" type="file" aria-describedby="file-upload-3-hint file-upload-3-error">
+  <input class="govuk-file-upload govuk-file-upload--error" id="file-upload-3" name="file-upload-3" type="file" data-module="govuk-file-upload" aria-describedby="file-upload-3-hint file-upload-3-error">
 </div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-hint-text.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-hint-text.html
index 952c50d0b..46fe09af4 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-hint-text.html
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-hint-text.html
@@ -5,5 +5,5 @@
   <div id="file-upload-2-hint" class="govuk-hint">
     Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.
   </div>
-  <input class="govuk-file-upload" id="file-upload-2" name="file-upload-2" type="file" aria-describedby="file-upload-2-hint">
+  <input class="govuk-file-upload" id="file-upload-2" name="file-upload-2" type="file" data-module="govuk-file-upload" aria-describedby="file-upload-2-hint">
 </div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-label-as-page-heading.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-label-as-page-heading.html
index 85845be54..18570a361 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-label-as-page-heading.html
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-label-as-page-heading.html
@@ -4,5 +4,5 @@
       Upload a file
     </label>
   </h1>
-  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file">
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload">
 </div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-optional-form-group-classes.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-optional-form-group-classes.html
index b5249c7b2..b67aea882 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-optional-form-group-classes.html
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-with-optional-form-group-classes.html
@@ -2,5 +2,5 @@
   <label class="govuk-label" for="file-upload-1">
     Upload a file
   </label>
-  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file">
+  <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" data-module="govuk-file-upload">
 </div>

Action run for 980b1c6fbc1bc170d03614183e989902a07b9a4f

github-actions[bot] commented 2 months ago

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..bf21e0eda 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -1624,6 +1624,170 @@
     }
   });

+  /**
+   * File upload component
+   *
+   * @preserve
+   */
+  class FileUpload extends GOVUKFrontendComponent {
+    /**
+     * @param {Element | null} $root - File input element
+     * @param {FileUploadConfig} [config] - File Upload config
+     */
+    constructor($root, config = {}) {
+      super($root);
+      this.$wrapper = void 0;
+      this.$button = void 0;
+      this.$status = void 0;
+      this.config = void 0;
+      this.i18n = void 0;
+      if (!(this.$root instanceof HTMLInputElement)) {
+        return;
+      }
+      if (this.$root.type !== 'file') {
+        throw new ElementError('File upload: Form field must be an input of type `file`.');
+      }
+      this.config = mergeConfigs(FileUpload.defaults, config, normaliseDataset(FileUpload, this.$root.dataset));
+      this.i18n = new I18n(this.config.i18n, {
+        locale: closestAttributeValue(this.$root, 'lang')
+      });
+      this.$label = document.querySelector(`[for="${this.$root.id}"]`);
+      if (!this.$label) {
+        throw new ElementError({
+          component: FileUpload,
+          identifier: 'No label'
+        });
+      }
+      const $wrapper = document.createElement('div');
+      $wrapper.className = 'govuk-file-upload-wrapper';
+      const $button = document.createElement('button');
+      $button.className = 'govuk-button govuk-button--secondary govuk-file-upload__button';
+      $button.type = 'button';
+      $button.innerText = this.i18n.t('selectFilesButton');
+      $button.addEventListener('click', this.onClick.bind(this));
+      const $status = document.createElement('span');
+      $status.className = 'govuk-body govuk-file-upload__status';
+      $status.innerText = this.i18n.t('filesSelectedDefault');
+      $status.setAttribute('role', 'status');
+      $wrapper.insertAdjacentElement('beforeend', $button);
+      $wrapper.insertAdjacentElement('beforeend', $status);
+      this.$root.insertAdjacentElement('afterend', $wrapper);
+      $wrapper.insertAdjacentElement('afterbegin', this.$root);
+      this.$wrapper = $wrapper;
+      this.$button = $button;
+      this.$status = $status;
+      this.$root.setAttribute('tabindex', '-1');
+      this.updateDisabledState();
+      this.observeDisabledState();
+      this.$root.addEventListener('change', this.onChange.bind(this));
+      this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
+      document.addEventListener('dragenter', this.onDragEnter.bind(this));
+      document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+    }
+    onChange() {
+      if (!('files' in this.$root)) {
+        return;
+      }
+      if (!this.$root.files) {
+        return;
+      }
+      const fileCount = this.$root.files.length;
+      if (!this.$status || !this.i18n) {
+        return;
+      }
+      if (fileCount === 0) {
+        this.$status.innerText = this.i18n.t('filesSelectedDefault');
+      } else if (fileCount === 1) {
+        this.$status.innerText = this.$root.files[0].name;
+      } else {
+        this.$status.innerText = this.i18n.t('filesSelected', {
+          count: fileCount
+        });
+      }
+    }
+    onClick() {
+      if (this.$label instanceof HTMLElement) {
+        this.$label.click();
+      }
+    }
+
+    /**
+     * When a file is dragged over the container, show a visual indicator that a
+     * file can be dropped here.
+     *
+     * @param {DragEvent} event - the drag event
+     */
+    onDragEnter(event) {
+      console.log(event);
+      this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+    }
+    onDragLeaveOrDrop() {
+      this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+    }
+    observeDisabledState() {
+      const observer = new MutationObserver(mutationList => {
+        for (const mutation of mutationList) {
+          console.log('mutation', mutation);
+          if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+            this.updateDisabledState();
+          }
+        }
+      });
+      observer.observe(this.$root, {
+        attributes: true
+      });
+    }
+    updateDisabledState() {
+      if (!(this.$root instanceof HTMLInputElement) || !(this.$button instanceof HTMLButtonElement)) {
+        return;
+      }
+      this.$button.disabled = this.$root.disabled;
+    }
+  }
+
+  /**
+   * File upload config
+   *
+   * @see {@link FileUpload.defaults}
+   * @typedef {object} FileUploadConfig
+   * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+   */
+
+  /**
+   * File upload translations
+   *
+   * @see {@link FileUpload.defaults.i18n}
+   * @typedef {object} FileUploadTranslations
+   *
+   * Messages used by the component
+   * @property {string} [selectFiles] - Text of button that opens file browser
+   * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+   *   many files have been selected
+   */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+   */
+  FileUpload.moduleName = 'govuk-file-upload';
+  FileUpload.defaults = Object.freeze({
+    i18n: {
+      selectFilesButton: 'Choose file',
+      filesSelectedDefault: 'No file chosen',
+      filesSelected: {
+        one: '%{count} file chosen',
+        other: '%{count} files chosen'
+      }
+    }
+  });
+  FileUpload.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
+
   /**
    * Header component
    *
@@ -2408,7 +2572,7 @@
       }
       return;
     }
-    const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [ServiceNavigation], [SkipLink], [Tabs]];
+    const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [FileUpload, config.fileUpload], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [ServiceNavigation], [SkipLink], [Tabs]];
     const options = {
       scope: (_config$scope = config.scope) != null ? _config$scope : document,
       onError: config.onError
@@ -2489,6 +2653,7 @@
    * @property {CharacterCountConfig} [characterCount] - Character Count config
    * @property {ErrorSummaryConfig} [errorSummary] - Error Summary config
    * @property {ExitThisPageConfig} [exitThisPage] - Exit This Page config
+   * @property {FileUploadConfig} [fileUpload] - File Upload config
    * @property {NotificationBannerConfig} [notificationBanner] - Notification Banner config
    * @property {PasswordInputConfig} [passwordInput] - Password input config
    */
@@ -2503,6 +2668,8 @@
    * @typedef {import('./components/error-summary/error-summary.mjs').ErrorSummaryConfig} ErrorSummaryConfig
    * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageConfig} ExitThisPageConfig
    * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageTranslations} ExitThisPageTranslations
+   * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadConfig} FileUploadConfig
+   * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadTranslations} FileUploadTranslations
    * @typedef {import('./components/notification-banner/notification-banner.mjs').NotificationBannerConfig} NotificationBannerConfig
    * @typedef {import('./components/password-input/password-input.mjs').PasswordInputConfig} PasswordInputConfig
    */
@@ -2538,6 +2705,7 @@
   exports.Component = GOVUKFrontendComponent;
   exports.ErrorSummary = ErrorSummary;
   exports.ExitThisPage = ExitThisPage;
+  exports.FileUpload = FileUpload;
   exports.Header = Header;
   exports.NotificationBanner = NotificationBanner;
   exports.PasswordInput = PasswordInput;
diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.mjs b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
index c72c0b957..3a8f41816 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -1618,6 +1618,170 @@ ExitThisPage.schema = Object.freeze({
   }
 });

+/**
+ * File upload component
+ *
+ * @preserve
+ */
+class FileUpload extends GOVUKFrontendComponent {
+  /**
+   * @param {Element | null} $root - File input element
+   * @param {FileUploadConfig} [config] - File Upload config
+   */
+  constructor($root, config = {}) {
+    super($root);
+    this.$wrapper = void 0;
+    this.$button = void 0;
+    this.$status = void 0;
+    this.config = void 0;
+    this.i18n = void 0;
+    if (!(this.$root instanceof HTMLInputElement)) {
+      return;
+    }
+    if (this.$root.type !== 'file') {
+      throw new ElementError('File upload: Form field must be an input of type `file`.');
+    }
+    this.config = mergeConfigs(FileUpload.defaults, config, normaliseDataset(FileUpload, this.$root.dataset));
+    this.i18n = new I18n(this.config.i18n, {
+      locale: closestAttributeValue(this.$root, 'lang')
+    });
+    this.$label = document.querySelector(`[for="${this.$root.id}"]`);
+    if (!this.$label) {
+      throw new ElementError({
+        component: FileUpload,
+        identifier: 'No label'
+      });
+    }
+    const $wrapper = document.createElement('div');
+    $wrapper.className = 'govuk-file-upload-wrapper';
+    const $button = document.createElement('button');
+    $button.className = 'govuk-button govuk-button--secondary govuk-file-upload__button';
+    $button.type = 'button';
+    $button.innerText = this.i18n.t('selectFilesButton');
+    $button.addEventListener('click', this.onClick.bind(this));
+    const $status = document.createElement('span');
+    $status.className = 'govuk-body govuk-file-upload__status';
+    $status.innerText = this.i18n.t('filesSelectedDefault');
+    $status.setAttribute('role', 'status');
+    $wrapper.insertAdjacentElement('beforeend', $button);
+    $wrapper.insertAdjacentElement('beforeend', $status);
+    this.$root.insertAdjacentElement('afterend', $wrapper);
+    $wrapper.insertAdjacentElement('afterbegin', this.$root);
+    this.$wrapper = $wrapper;
+    this.$button = $button;
+    this.$status = $status;
+    this.$root.setAttribute('tabindex', '-1');
+    this.updateDisabledState();
+    this.observeDisabledState();
+    this.$root.addEventListener('change', this.onChange.bind(this));
+    this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
+    document.addEventListener('dragenter', this.onDragEnter.bind(this));
+    document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+  }
+  onChange() {
+    if (!('files' in this.$root)) {
+      return;
+    }
+    if (!this.$root.files) {
+      return;
+    }
+    const fileCount = this.$root.files.length;
+    if (!this.$status || !this.i18n) {
+      return;
+    }
+    if (fileCount === 0) {
+      this.$status.innerText = this.i18n.t('filesSelectedDefault');
+    } else if (fileCount === 1) {
+      this.$status.innerText = this.$root.files[0].name;
+    } else {
+      this.$status.innerText = this.i18n.t('filesSelected', {
+        count: fileCount
+      });
+    }
+  }
+  onClick() {
+    if (this.$label instanceof HTMLElement) {
+      this.$label.click();
+    }
+  }
+
+  /**
+   * When a file is dragged over the container, show a visual indicator that a
+   * file can be dropped here.
+   *
+   * @param {DragEvent} event - the drag event
+   */
+  onDragEnter(event) {
+    console.log(event);
+    this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+  }
+  onDragLeaveOrDrop() {
+    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+  }
+  observeDisabledState() {
+    const observer = new MutationObserver(mutationList => {
+      for (const mutation of mutationList) {
+        console.log('mutation', mutation);
+        if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+          this.updateDisabledState();
+        }
+      }
+    });
+    observer.observe(this.$root, {
+      attributes: true
+    });
+  }
+  updateDisabledState() {
+    if (!(this.$root instanceof HTMLInputElement) || !(this.$button instanceof HTMLButtonElement)) {
+      return;
+    }
+    this.$button.disabled = this.$root.disabled;
+  }
+}
+
+/**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+/**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ *   many files have been selected
+ */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+  i18n: {
+    selectFilesButton: 'Choose file',
+    filesSelectedDefault: 'No file chosen',
+    filesSelected: {
+      one: '%{count} file chosen',
+      other: '%{count} files chosen'
+    }
+  }
+});
+FileUpload.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+
 /**
  * Header component
  *
@@ -2402,7 +2566,7 @@ function initAll(config) {
     }
     return;
   }
-  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [ServiceNavigation], [SkipLink], [Tabs]];
+  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [FileUpload, config.fileUpload], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [ServiceNavigation], [SkipLink], [Tabs]];
   const options = {
     scope: (_config$scope = config.scope) != null ? _config$scope : document,
     onError: config.onError
@@ -2483,6 +2647,7 @@ function createAll(Component, config, createAllOptions) {
  * @property {CharacterCountConfig} [characterCount] - Character Count config
  * @property {ErrorSummaryConfig} [errorSummary] - Error Summary config
  * @property {ExitThisPageConfig} [exitThisPage] - Exit This Page config
+ * @property {FileUploadConfig} [fileUpload] - File Upload config
  * @property {NotificationBannerConfig} [notificationBanner] - Notification Banner config
  * @property {PasswordInputConfig} [passwordInput] - Password input config
  */
@@ -2497,6 +2662,8 @@ function createAll(Component, config, createAllOptions) {
  * @typedef {import('./components/error-summary/error-summary.mjs').ErrorSummaryConfig} ErrorSummaryConfig
  * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageConfig} ExitThisPageConfig
  * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageTranslations} ExitThisPageTranslations
+ * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadConfig} FileUploadConfig
+ * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadTranslations} FileUploadTranslations
  * @typedef {import('./components/notification-banner/notification-banner.mjs').NotificationBannerConfig} NotificationBannerConfig
  * @typedef {import('./components/password-input/password-input.mjs').PasswordInputConfig} PasswordInputConfig
  */
@@ -2525,5 +2692,5 @@ function createAll(Component, config, createAllOptions) {
  * @property {OnErrorCallback<T>} [onError] - callback function if error throw by component on init
  */

-export { Accordion, Button, CharacterCount, Checkboxes, GOVUKFrontendComponent as Component, ErrorSummary, ExitThisPage, Header, NotificationBanner, PasswordInput, Radios, ServiceNavigation, SkipLink, Tabs, createAll, initAll, isSupported, version };
+export { Accordion, Button, CharacterCount, Checkboxes, GOVUKFrontendComponent as Component, ErrorSummary, ExitThisPage, FileUpload, Header, NotificationBanner, PasswordInput, Radios, ServiceNavigation, SkipLink, Tabs, createAll, initAll, isSupported, version };
 //# sourceMappingURL=all.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/all.mjs b/packages/govuk-frontend/dist/govuk/all.mjs
index 1d37a44bd..8c5112020 100644
--- a/packages/govuk-frontend/dist/govuk/all.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.mjs
@@ -5,6 +5,7 @@ export { CharacterCount } from './components/character-count/character-count.mjs
 export { Checkboxes } from './components/checkboxes/checkboxes.mjs';
 export { ErrorSummary } from './components/error-summary/error-summary.mjs';
 export { ExitThisPage } from './components/exit-this-page/exit-this-page.mjs';
+export { FileUpload } from './components/file-upload/file-upload.mjs';
 export { Header } from './components/header/header.mjs';
 export { NotificationBanner } from './components/notification-banner/notification-banner.mjs';
 export { PasswordInput } from './components/password-input/password-input.mjs';
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss b/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss
index af3de0ee9..34e781c51 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss
@@ -46,6 +46,56 @@
       cursor: not-allowed;
     }
   }
+
+  .govuk-file-upload-wrapper {
+    display: inline-flex;
+    align-items: baseline;
+    position: relative;
+  }
+
+  .govuk-file-upload-wrapper--show-dropzone {
+    $dropzone-padding: govuk-spacing(2);
+    $dropzone-offset: $dropzone-padding + $govuk-border-width-form-element;
+
+    // Add negative margins to all sides so that content doesn't jump due to
+    // the addition of the padding and border.
+    margin: -$dropzone-offset;
+    padding: $dropzone-padding;
+    border: $govuk-border-width-form-element dashed $govuk-input-border-colour;
+    background-color: $govuk-body-background-colour;
+
+    .govuk-file-upload__button,
+    .govuk-file-upload__status {
+      // When the dropzone is hovered over, make these aspects not accept
+      // mouse events, so dropped files fall through to the input beneath them
+      pointer-events: none;
+    }
+  }
+
+  .govuk-file-upload-wrapper .govuk-file-upload {
+    // Make the native control take up the entire space of the element, but
+    // invisible and behind the other elements until we need it
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    margin: 0;
+    padding: 0;
+    opacity: 0;
+  }
+
+  .govuk-file-upload__button {
+    width: auto;
+    margin-bottom: 0;
+    flex-grow: 0;
+    flex-shrink: 0;
+  }
+
+  .govuk-file-upload__status {
+    margin-bottom: 0;
+    margin-left: govuk-spacing(2);
+  }
 }

 /*# sourceMappingURL=_index.scss.map */
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js
new file mode 100644
index 000000000..220e675bc
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js
@@ -0,0 +1,613 @@
+(function (global, factory) {
+  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+  typeof define === 'function' && define.amd ? define(['exports'], factory) :
+  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {}));
+})(this, (function (exports) { 'use strict';
+
+  function closestAttributeValue($element, attributeName) {
+    const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
+    return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
+  }
+
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
+      }
+    }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @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') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
+      const keyParts = key.split('.');
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
+        }
+      }
+    }
+    return newObject[namespace];
+  }
+  function isInitialised($root, moduleName) {
+    return $root instanceof HTMLElement && $root.hasAttribute(`data-${moduleName}-init`);
+  }
+
+  /**
+   * Checks if GOV.UK Frontend is supported on this page
+   *
+   * Some browsers will load and run our JavaScript but GOV.UK Frontend
+   * won't be supported.
+   *
+   * @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support
+   * @returns {boolean} Whether GOV.UK Frontend is supported on this page
+   */
+  function isSupported($scope = document.body) {
+    if (!$scope) {
+      return false;
+    }
+    return $scope.classList.contains('govuk-frontend-supported');
+  }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
+  function formatErrorMessage(Component, message) {
+    return `${Component.moduleName}: ${message}`;
+  }
+
+  /**
+   * Schema for component config
+   *
+   * @typedef {object} Schema
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
+  /**
+   * Schema condition for component config
+   *
+   * @typedef {object} SchemaCondition
+   * @property {string[]} required - List of required config fields
+   * @property {string} errorMessage - Error message when required config fields not provided
+   */
+  /**
+   * @typedef ComponentWithModuleName
+   * @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);
+      this.name = 'GOVUKFrontendError';
+    }
+  }
+  class SupportError extends GOVUKFrontendError {
+    /**
+     * Checks if GOV.UK Frontend is supported on this page
+     *
+     * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
+     */
+    constructor($scope = document.body) {
+      const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
+      super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
+      this.name = 'SupportError';
+    }
+  }
+  class ElementError extends GOVUKFrontendError {
+    constructor(messageOrOptions) {
+      let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
+      if (typeof messageOrOptions === 'object') {
+        const {
+          component,
+          identifier,
+          element,
+          expectedType
+        } = messageOrOptions;
+        message = identifier;
+        message += element ? ` is not of type ${expectedType != null ? expectedType : 'HTMLElement'}` : ' not found';
+        message = formatErrorMessage(component, message);
+      }
+      super(message);
+      this.name = 'ElementError';
+    }
+  }
+  class InitError extends GOVUKFrontendError {
+    constructor(componentOrMessage) {
+      const message = typeof componentOrMessage === 'string' ? componentOrMessage : formatErrorMessage(componentOrMessage, `Root element (\`$root\`) already initialised`);
+      super(message);
+      this.name = 'InitError';
+    }
+  }
+  /**
+   * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
+   */
+
+  class GOVUKFrontendComponent {
+    /**
+     * Returns the root element of the component
+     *
+     * @protected
+     * @returns {RootElementType} - the root element of component
+     */
+    get $root() {
+      return this._$root;
+    }
+    constructor($root) {
+      this._$root = void 0;
+      const childConstructor = this.constructor;
+      if (typeof childConstructor.moduleName !== 'string') {
+        throw new InitError(`\`moduleName\` not defined in component`);
+      }
+      if (!($root instanceof childConstructor.elementType)) {
+        throw new ElementError({
+          element: $root,
+          component: childConstructor,
+          identifier: 'Root element (`$root`)',
+          expectedType: childConstructor.elementType.name
+        });
+      } else {
+        this._$root = $root;
+      }
+      childConstructor.checkSupport();
+      this.checkInitialised();
+      const moduleName = childConstructor.moduleName;
+      this.$root.setAttribute(`data-${moduleName}-init`, '');
+    }
+    checkInitialised() {
+      const constructor = this.constructor;
+      const moduleName = constructor.moduleName;
+      if (moduleName && isInitialised(this.$root, moduleName)) {
+        throw new InitError(constructor);
+      }
+    }
+    static checkSupport() {
+      if (!isSupported()) {
+        throw new SupportError();
+      }
+    }
+  }
+
+  /**
+   * @typedef ChildClass
+   * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+   */
+
+  /**
+   * @typedef {typeof GOVUKFrontendComponent & ChildClass} ChildClassConstructor
+   */
+  GOVUKFrontendComponent.elementType = HTMLElement;
+
+  class I18n {
+    constructor(translations = {}, config = {}) {
+      var _config$locale;
+      this.translations = void 0;
+      this.locale = void 0;
+      this.translations = translations;
+      this.locale = (_config$locale = config.locale) != null ? _config$locale : document.documentElement.lang || 'en';
+    }
+    t(lookupKey, options) {
+      if (!lookupKey) {
+        throw new Error('i18n: lookup key missing');
+      }
+      let translation = this.translations[lookupKey];
+      if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+        const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+        if (translationPluralForm) {
+          translation = translationPluralForm;
+        }
+      }
+      if (typeof translation === 'string') {
+        if (translation.match(/%{(.\S+)}/)) {
+          if (!options) {
+            throw new Error('i18n: cannot replace placeholders in string if no option data provided');
+          }
+          return this.replacePlaceholders(translation, options);
+        }
+        return translation;
+      }
+      return lookupKey;
+    }
+    replacePlaceholders(translationString, options) {
+      const formatter = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : undefined;
+      return translationString.replace(/%{(.\S+)}/g, function (placeholderWithBraces, placeholderKey) {
+        if (Object.prototype.hasOwnProperty.call(options, placeholderKey)) {
+          const placeholderValue = options[placeholderKey];
+          if (placeholderValue === false || typeof placeholderValue !== 'number' && typeof placeholderValue !== 'string') {
+            return '';
+          }
+          if (typeof placeholderValue === 'number') {
+            return formatter ? formatter.format(placeholderValue) : `${placeholderValue}`;
+          }
+          return placeholderValue;
+        }
+        throw new Error(`i18n: no data found to replace ${placeholderWithBraces} placeholder in string`);
+      });
+    }
+    hasIntlPluralRulesSupport() {
+      return Boolean('PluralRules' in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length);
+    }
+    getPluralSuffix(lookupKey, count) {
+      count = Number(count);
+      if (!isFinite(count)) {
+        return 'other';
+      }
+      const translation = this.translations[lookupKey];
+      const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
+      if (typeof translation === 'object') {
+        if (preferredForm in translation) {
+          return preferredForm;
+        } else if ('other' in translation) {
+          console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+          return 'other';
+        }
+      }
+      throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
+    }
+    selectPluralFormUsingFallbackRules(count) {
+      count = Math.abs(Math.floor(count));
+      const ruleset = this.getPluralRulesForLocale();
+      if (ruleset) {
+        return I18n.pluralRules[ruleset](count);
+      }
+      return 'other';
+    }
+    getPluralRulesForLocale() {
+      const localeShort = this.locale.split('-')[0];
+      for (const pluralRule in I18n.pluralRulesMap) {
+        const languages = I18n.pluralRulesMap[pluralRule];
+        if (languages.includes(this.locale) || languages.includes(localeShort)) {
+          return pluralRule;
+        }
+      }
+    }
+  }
+  I18n.pluralRulesMap = {
+    arabic: ['ar'],
+    chinese: ['my', 'zh', 'id', 'ja', 'jv', 'ko', 'ms', 'th', 'vi'],
+    french: ['hy', 'bn', 'fr', 'gu', 'hi', 'fa', 'pa', 'zu'],
+    german: ['af', 'sq', 'az', 'eu', 'bg', 'ca', 'da', 'nl', 'en', 'et', 'fi', 'ka', 'de', 'el', 'hu', 'lb', 'no', 'so', 'sw', 'sv', 'ta', 'te', 'tr', 'ur'],
+    irish: ['ga'],
+    russian: ['ru', 'uk'],
+    scottish: ['gd'],
+    spanish: ['pt-PT', 'it', 'es'],
+    welsh: ['cy']
+  };
+  I18n.pluralRules = {
+    arabic(n) {
+      if (n === 0) {
+        return 'zero';
+      }
+      if (n === 1) {
+        return 'one';
+      }
+      if (n === 2) {
+        return 'two';
+      }
+      if (n % 100 >= 3 && n % 100 <= 10) {
+        return 'few';
+      }
+      if (n % 100 >= 11 && n % 100 <= 99) {
+        return 'many';
+      }
+      return 'other';
+    },
+    chinese() {
+      return 'other';
+    },
+    french(n) {
+      return n === 0 || n === 1 ? 'one' : 'other';
+    },
+    german(n) {
+      return n === 1 ? 'one' : 'other';
+    },
+    irish(n) {
+      if (n === 1) {
+        return 'one';
+      }
+      if (n === 2) {
+        return 'two';
+      }
+      if (n >= 3 && n <= 6) {
+        return 'few';
+      }
+      if (n >= 7 && n <= 10) {
+        return 'many';
+      }
+      return 'other';
+    },
+    russian(n) {
+      const lastTwo = n % 100;
+      const last = lastTwo % 10;
+      if (last === 1 && lastTwo !== 11) {
+        return 'one';
+      }
+      if (last >= 2 && last <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) {
+        return 'few';
+      }
+      if (last === 0 || last >= 5 && last <= 9 || lastTwo >= 11 && lastTwo <= 14) {
+        return 'many';
+      }
+      return 'other';
+    },
+    scottish(n) {
+      if (n === 1 || n === 11) {
+        return 'one';
+      }
+      if (n === 2 || n === 12) {
+        return 'two';
+      }
+      if (n >= 3 && n <= 10 || n >= 13 && n <= 19) {
+        return 'few';
+      }
+      return 'other';
+    },
+    spanish(n) {
+      if (n === 1) {
+        return 'one';
+      }
+      if (n % 1000000 === 0 && n !== 0) {
+        return 'many';
+      }
+      return 'other';
+    },
+    welsh(n) {
+      if (n === 0) {
+        return 'zero';
+      }
+      if (n === 1) {
+        return 'one';
+      }
+      if (n === 2) {
+        return 'two';
+      }
+      if (n === 3) {
+        return 'few';
+      }
+      if (n === 6) {
+        return 'many';
+      }
+      return 'other';
+    }
+  };
+
+  /**
+   * File upload component
+   *
+   * @preserve
+   */
+  class FileUpload extends GOVUKFrontendComponent {
+    /**
+     * @param {Element | null} $root - File input element
+     * @param {FileUploadConfig} [config] - File Upload config
+     */
+    constructor($root, config = {}) {
+      super($root);
+      this.$wrapper = void 0;
+      this.$button = void 0;
+      this.$status = void 0;
+      this.config = void 0;
+      this.i18n = void 0;
+      if (!(this.$root instanceof HTMLInputElement)) {
+        return;
+      }
+      if (this.$root.type !== 'file') {
+        throw new ElementError('File upload: Form field must be an input of type `file`.');
+      }
+      this.config = mergeConfigs(FileUpload.defaults, config, normaliseDataset(FileUpload, this.$root.dataset));
+      this.i18n = new I18n(this.config.i18n, {
+        locale: closestAttributeValue(this.$root, 'lang')
+      });
+      this.$label = document.querySelector(`[for="${this.$root.id}"]`);
+      if (!this.$label) {
+        throw new ElementError({
+          component: FileUpload,
+          identifier: 'No label'
+        });
+      }
+      const $wrapper = document.createElement('div');
+      $wrapper.className = 'govuk-file-upload-wrapper';
+      const $button = document.createElement('button');
+      $button.className = 'govuk-button govuk-button--secondary govuk-file-upload__button';
+      $button.type = 'button';
+      $button.innerText = this.i18n.t('selectFilesButton');
+      $button.addEventListener('click', this.onClick.bind(this));
+      const $status = document.createElement('span');
+      $status.className = 'govuk-body govuk-file-upload__status';
+      $status.innerText = this.i18n.t('filesSelectedDefault');
+      $status.setAttribute('role', 'status');
+      $wrapper.insertAdjacentElement('beforeend', $button);
+      $wrapper.insertAdjacentElement('beforeend', $status);
+      this.$root.insertAdjacentElement('afterend', $wrapper);
+      $wrapper.insertAdjacentElement('afterbegin', this.$root);
+      this.$wrapper = $wrapper;
+      this.$button = $button;
+      this.$status = $status;
+      this.$root.setAttribute('tabindex', '-1');
+      this.updateDisabledState();
+      this.observeDisabledState();
+      this.$root.addEventListener('change', this.onChange.bind(this));
+      this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
+      document.addEventListener('dragenter', this.onDragEnter.bind(this));
+      document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+    }
+    onChange() {
+      if (!('files' in this.$root)) {
+        return;
+      }
+      if (!this.$root.files) {
+        return;
+      }
+      const fileCount = this.$root.files.length;
+      if (!this.$status || !this.i18n) {
+        return;
+      }
+      if (fileCount === 0) {
+        this.$status.innerText = this.i18n.t('filesSelectedDefault');
+      } else if (fileCount === 1) {
+        this.$status.innerText = this.$root.files[0].name;
+      } else {
+        this.$status.innerText = this.i18n.t('filesSelected', {
+          count: fileCount
+        });
+      }
+    }
+    onClick() {
+      if (this.$label instanceof HTMLElement) {
+        this.$label.click();
+      }
+    }
+
+    /**
+     * When a file is dragged over the container, show a visual indicator that a
+     * file can be dropped here.
+     *
+     * @param {DragEvent} event - the drag event
+     */
+    onDragEnter(event) {
+      console.log(event);
+      this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+    }
+    onDragLeaveOrDrop() {
+      this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+    }
+    observeDisabledState() {
+      const observer = new MutationObserver(mutationList => {
+        for (const mutation of mutationList) {
+          console.log('mutation', mutation);
+          if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+            this.updateDisabledState();
+          }
+        }
+      });
+      observer.observe(this.$root, {
+        attributes: true
+      });
+    }
+    updateDisabledState() {
+      if (!(this.$root instanceof HTMLInputElement) || !(this.$button instanceof HTMLButtonElement)) {
+        return;
+      }
+      this.$button.disabled = this.$root.disabled;
+    }
+  }
+
+  /**
+   * File upload config
+   *
+   * @see {@link FileUpload.defaults}
+   * @typedef {object} FileUploadConfig
+   * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+   */
+
+  /**
+   * File upload translations
+   *
+   * @see {@link FileUpload.defaults.i18n}
+   * @typedef {object} FileUploadTranslations
+   *
+   * Messages used by the component
+   * @property {string} [selectFiles] - Text of button that opens file browser
+   * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+   *   many files have been selected
+   */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+   */
+  FileUpload.moduleName = 'govuk-file-upload';
+  FileUpload.defaults = Object.freeze({
+    i18n: {
+      selectFilesButton: 'Choose file',
+      filesSelectedDefault: 'No file chosen',
+      filesSelected: {
+        one: '%{count} file chosen',
+        other: '%{count} files chosen'
+      }
+    }
+  });
+  FileUpload.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
+
+  exports.FileUpload = FileUpload;
+
+}));
+//# sourceMappingURL=file-upload.bundle.js.map
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs
new file mode 100644
index 000000000..beb37af21
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs
@@ -0,0 +1,605 @@
+function closestAttributeValue($element, attributeName) {
+  const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
+  return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
+}
+
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @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') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
+    const keyParts = key.split('.');
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
+      }
+    }
+  }
+  return newObject[namespace];
+}
+function isInitialised($root, moduleName) {
+  return $root instanceof HTMLElement && $root.hasAttribute(`data-${moduleName}-init`);
+}
+
+/**
+ * Checks if GOV.UK Frontend is supported on this page
+ *
+ * Some browsers will load and run our JavaScript but GOV.UK Frontend
+ * won't be supported.
+ *
+ * @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support
+ * @returns {boolean} Whether GOV.UK Frontend is supported on this page
+ */
+function isSupported($scope = document.body) {
+  if (!$scope) {
+    return false;
+  }
+  return $scope.classList.contains('govuk-frontend-supported');
+}
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
+function formatErrorMessage(Component, message) {
+  return `${Component.moduleName}: ${message}`;
+}
+
+/**
+ * Schema for component config
+ *
+ * @typedef {object} Schema
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
+/**
+ * Schema condition for component config
+ *
+ * @typedef {object} SchemaCondition
+ * @property {string[]} required - List of required config fields
+ * @property {string} errorMessage - Error message when required config fields not provided
+ */
+/**
+ * @typedef ComponentWithModuleName
+ * @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);
+    this.name = 'GOVUKFrontendError';
+  }
+}
+class SupportError extends GOVUKFrontendError {
+  /**
+   * Checks if GOV.UK Frontend is supported on this page
+   *
+   * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
+   */
+  constructor($scope = document.body) {
+    const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
+    super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
+    this.name = 'SupportError';
+  }
+}
+class ElementError extends GOVUKFrontendError {
+  constructor(messageOrOptions) {
+    let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
+    if (typeof messageOrOptions === 'object') {
+      const {
+        component,
+        identifier,
+        element,
+        expectedType
+      } = messageOrOptions;
+      message = identifier;
+      message += element ? ` is not of type ${expectedType != null ? expectedType : 'HTMLElement'}` : ' not found';
+      message = formatErrorMessage(component, message);
+    }
+    super(message);
+    this.name = 'ElementError';
+  }
+}
+class InitError extends GOVUKFrontendError {
+  constructor(componentOrMessage) {
+    const message = typeof componentOrMessage === 'string' ? componentOrMessage : formatErrorMessage(componentOrMessage, `Root element (\`$root\`) already initialised`);
+    super(message);
+    this.name = 'InitError';
+  }
+}
+/**
+ * @typedef {import('../common/index.mjs').ComponentWithModuleName} ComponentWithModuleName
+ */
+
+class GOVUKFrontendComponent {
+  /**
+   * Returns the root element of the component
+   *
+   * @protected
+   * @returns {RootElementType} - the root element of component
+   */
+  get $root() {
+    return this._$root;
+  }
+  constructor($root) {
+    this._$root = void 0;
+    const childConstructor = this.constructor;
+    if (typeof childConstructor.moduleName !== 'string') {
+      throw new InitError(`\`moduleName\` not defined in component`);
+    }
+    if (!($root instanceof childConstructor.elementType)) {
+      throw new ElementError({
+        element: $root,
+        component: childConstructor,
+        identifier: 'Root element (`$root`)',
+        expectedType: childConstructor.elementType.name
+      });
+    } else {
+      this._$root = $root;
+    }
+    childConstructor.checkSupport();
+    this.checkInitialised();
+    const moduleName = childConstructor.moduleName;
+    this.$root.setAttribute(`data-${moduleName}-init`, '');
+  }
+  checkInitialised() {
+    const constructor = this.constructor;
+    const moduleName = constructor.moduleName;
+    if (moduleName && isInitialised(this.$root, moduleName)) {
+      throw new InitError(constructor);
+    }
+  }
+  static checkSupport() {
+    if (!isSupported()) {
+      throw new SupportError();
+    }
+  }
+}
+
+/**
+ * @typedef ChildClass
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+ */
+
+/**
+ * @typedef {typeof GOVUKFrontendComponent & ChildClass} ChildClassConstructor
+ */
+GOVUKFrontendComponent.elementType = HTMLElement;
+
+class I18n {
+  constructor(translations = {}, config = {}) {
+    var _config$locale;
+    this.translations = void 0;
+    this.locale = void 0;
+    this.translations = translations;
+    this.locale = (_config$locale = config.locale) != null ? _config$locale : document.documentElement.lang || 'en';
+  }
+  t(lookupKey, options) {
+    if (!lookupKey) {
+      throw new Error('i18n: lookup key missing');
+    }
+    let translation = this.translations[lookupKey];
+    if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+      const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+      if (translationPluralForm) {
+        translation = translationPluralForm;
+      }
+    }
+    if (typeof translation === 'string') {
+      if (translation.match(/%{(.\S+)}/)) {
+        if (!options) {
+          throw new Error('i18n: cannot replace placeholders in string if no option data provided');
+        }
+        return this.replacePlaceholders(translation, options);
+      }
+      return translation;
+    }
+    return lookupKey;
+  }
+  replacePlaceholders(translationString, options) {
+    const formatter = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : undefined;
+    return translationString.replace(/%{(.\S+)}/g, function (placeholderWithBraces, placeholderKey) {
+      if (Object.prototype.hasOwnProperty.call(options, placeholderKey)) {
+        const placeholderValue = options[placeholderKey];
+        if (placeholderValue === false || typeof placeholderValue !== 'number' && typeof placeholderValue !== 'string') {
+          return '';
+        }
+        if (typeof placeholderValue === 'number') {
+          return formatter ? formatter.format(placeholderValue) : `${placeholderValue}`;
+        }
+        return placeholderValue;
+      }
+      throw new Error(`i18n: no data found to replace ${placeholderWithBraces} placeholder in string`);
+    });
+  }
+  hasIntlPluralRulesSupport() {
+    return Boolean('PluralRules' in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length);
+  }
+  getPluralSuffix(lookupKey, count) {
+    count = Number(count);
+    if (!isFinite(count)) {
+      return 'other';
+    }
+    const translation = this.translations[lookupKey];
+    const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
+    if (typeof translation === 'object') {
+      if (preferredForm in translation) {
+        return preferredForm;
+      } else if ('other' in translation) {
+        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+        return 'other';
+      }
+    }
+    throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
+  }
+  selectPluralFormUsingFallbackRules(count) {
+    count = Math.abs(Math.floor(count));
+    const ruleset = this.getPluralRulesForLocale();
+    if (ruleset) {
+      return I18n.pluralRules[ruleset](count);
+    }
+    return 'other';
+  }
+  getPluralRulesForLocale() {
+    const localeShort = this.locale.split('-')[0];
+    for (const pluralRule in I18n.pluralRulesMap) {
+      const languages = I18n.pluralRulesMap[pluralRule];
+      if (languages.includes(this.locale) || languages.includes(localeShort)) {
+        return pluralRule;
+      }
+    }
+  }
+}
+I18n.pluralRulesMap = {
+  arabic: ['ar'],
+  chinese: ['my', 'zh', 'id', 'ja', 'jv', 'ko', 'ms', 'th', 'vi'],
+  french: ['hy', 'bn', 'fr', 'gu', 'hi', 'fa', 'pa', 'zu'],
+  german: ['af', 'sq', 'az', 'eu', 'bg', 'ca', 'da', 'nl', 'en', 'et', 'fi', 'ka', 'de', 'el', 'hu', 'lb', 'no', 'so', 'sw', 'sv', 'ta', 'te', 'tr', 'ur'],
+  irish: ['ga'],
+  russian: ['ru', 'uk'],
+  scottish: ['gd'],
+  spanish: ['pt-PT', 'it', 'es'],
+  welsh: ['cy']
+};
+I18n.pluralRules = {
+  arabic(n) {
+    if (n === 0) {
+      return 'zero';
+    }
+    if (n === 1) {
+      return 'one';
+    }
+    if (n === 2) {
+      return 'two';
+    }
+    if (n % 100 >= 3 && n % 100 <= 10) {
+      return 'few';
+    }
+    if (n % 100 >= 11 && n % 100 <= 99) {
+      return 'many';
+    }
+    return 'other';
+  },
+  chinese() {
+    return 'other';
+  },
+  french(n) {
+    return n === 0 || n === 1 ? 'one' : 'other';
+  },
+  german(n) {
+    return n === 1 ? 'one' : 'other';
+  },
+  irish(n) {
+    if (n === 1) {
+      return 'one';
+    }
+    if (n === 2) {
+      return 'two';
+    }
+    if (n >= 3 && n <= 6) {
+      return 'few';
+    }
+    if (n >= 7 && n <= 10) {
+      return 'many';
+    }
+    return 'other';
+  },
+  russian(n) {
+    const lastTwo = n % 100;
+    const last = lastTwo % 10;
+    if (last === 1 && lastTwo !== 11) {
+      return 'one';
+    }
+    if (last >= 2 && last <= 4 && !(lastTwo >= 12 && lastTwo <= 14)) {
+      return 'few';
+    }
+    if (last === 0 || last >= 5 && last <= 9 || lastTwo >= 11 && lastTwo <= 14) {
+      return 'many';
+    }
+    return 'other';
+  },
+  scottish(n) {
+    if (n === 1 || n === 11) {
+      return 'one';
+    }
+    if (n === 2 || n === 12) {
+      return 'two';
+    }
+    if (n >= 3 && n <= 10 || n >= 13 && n <= 19) {
+      return 'few';
+    }
+    return 'other';
+  },
+  spanish(n) {
+    if (n === 1) {
+      return 'one';
+    }
+    if (n % 1000000 === 0 && n !== 0) {
+      return 'many';
+    }
+    return 'other';
+  },
+  welsh(n) {
+    if (n === 0) {
+      return 'zero';
+    }
+    if (n === 1) {
+      return 'one';
+    }
+    if (n === 2) {
+      return 'two';
+    }
+    if (n === 3) {
+      return 'few';
+    }
+    if (n === 6) {
+      return 'many';
+    }
+    return 'other';
+  }
+};
+
+/**
+ * File upload component
+ *
+ * @preserve
+ */
+class FileUpload extends GOVUKFrontendComponent {
+  /**
+   * @param {Element | null} $root - File input element
+   * @param {FileUploadConfig} [config] - File Upload config
+   */
+  constructor($root, config = {}) {
+    super($root);
+    this.$wrapper = void 0;
+    this.$button = void 0;
+    this.$status = void 0;
+    this.config = void 0;
+    this.i18n = void 0;
+    if (!(this.$root instanceof HTMLInputElement)) {
+      return;
+    }
+    if (this.$root.type !== 'file') {
+      throw new ElementError('File upload: Form field must be an input of type `file`.');
+    }
+    this.config = mergeConfigs(FileUpload.defaults, config, normaliseDataset(FileUpload, this.$root.dataset));
+    this.i18n = new I18n(this.config.i18n, {
+      locale: closestAttributeValue(this.$root, 'lang')
+    });
+    this.$label = document.querySelector(`[for="${this.$root.id}"]`);
+    if (!this.$label) {
+      throw new ElementError({
+        component: FileUpload,
+        identifier: 'No label'
+      });
+    }
+    const $wrapper = document.createElement('div');
+    $wrapper.className = 'govuk-file-upload-wrapper';
+    const $button = document.createElement('button');
+    $button.className = 'govuk-button govuk-button--secondary govuk-file-upload__button';
+    $button.type = 'button';
+    $button.innerText = this.i18n.t('selectFilesButton');
+    $button.addEventListener('click', this.onClick.bind(this));
+    const $status = document.createElement('span');
+    $status.className = 'govuk-body govuk-file-upload__status';
+    $status.innerText = this.i18n.t('filesSelectedDefault');
+    $status.setAttribute('role', 'status');
+    $wrapper.insertAdjacentElement('beforeend', $button);
+    $wrapper.insertAdjacentElement('beforeend', $status);
+    this.$root.insertAdjacentElement('afterend', $wrapper);
+    $wrapper.insertAdjacentElement('afterbegin', this.$root);
+    this.$wrapper = $wrapper;
+    this.$button = $button;
+    this.$status = $status;
+    this.$root.setAttribute('tabindex', '-1');
+    this.updateDisabledState();
+    this.observeDisabledState();
+    this.$root.addEventListener('change', this.onChange.bind(this));
+    this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
+    document.addEventListener('dragenter', this.onDragEnter.bind(this));
+    document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+  }
+  onChange() {
+    if (!('files' in this.$root)) {
+      return;
+    }
+    if (!this.$root.files) {
+      return;
+    }
+    const fileCount = this.$root.files.length;
+    if (!this.$status || !this.i18n) {
+      return;
+    }
+    if (fileCount === 0) {
+      this.$status.innerText = this.i18n.t('filesSelectedDefault');
+    } else if (fileCount === 1) {
+      this.$status.innerText = this.$root.files[0].name;
+    } else {
+      this.$status.innerText = this.i18n.t('filesSelected', {
+        count: fileCount
+      });
+    }
+  }
+  onClick() {
+    if (this.$label instanceof HTMLElement) {
+      this.$label.click();
+    }
+  }
+
+  /**
+   * When a file is dragged over the container, show a visual indicator that a
+   * file can be dropped here.
+   *
+   * @param {DragEvent} event - the drag event
+   */
+  onDragEnter(event) {
+    console.log(event);
+    this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+  }
+  onDragLeaveOrDrop() {
+    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+  }
+  observeDisabledState() {
+    const observer = new MutationObserver(mutationList => {
+      for (const mutation of mutationList) {
+        console.log('mutation', mutation);
+        if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+          this.updateDisabledState();
+        }
+      }
+    });
+    observer.observe(this.$root, {
+      attributes: true
+    });
+  }
+  updateDisabledState() {
+    if (!(this.$root instanceof HTMLInputElement) || !(this.$button instanceof HTMLButtonElement)) {
+      return;
+    }
+    this.$button.disabled = this.$root.disabled;
+  }
+}
+
+/**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+/**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ *   many files have been selected
+ */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+  i18n: {
+    selectFilesButton: 'Choose file',
+    filesSelectedDefault: 'No file chosen',
+    filesSelected: {
+      one: '%{count} file chosen',
+      other: '%{count} files chosen'
+    }
+  }
+});
+FileUpload.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+
+export { FileUpload };
+//# sourceMappingURL=file-upload.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
new file mode 100644
index 000000000..d44dd2308
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
@@ -0,0 +1,173 @@
+import { closestAttributeValue } from '../../common/closest-attribute-value.mjs';
+import { mergeConfigs } from '../../common/index.mjs';
+import { normaliseDataset } from '../../common/normalise-dataset.mjs';
+import { ElementError } from '../../errors/index.mjs';
+import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
+import { I18n } from '../../i18n.mjs';
+
+/**
+ * File upload component
+ *
+ * @preserve
+ */
+class FileUpload extends GOVUKFrontendComponent {
+  /**
+   * @param {Element | null} $root - File input element
+   * @param {FileUploadConfig} [config] - File Upload config
+   */
+  constructor($root, config = {}) {
+    super($root);
+    this.$wrapper = void 0;
+    this.$button = void 0;
+    this.$status = void 0;
+    this.config = void 0;
+    this.i18n = void 0;
+    if (!(this.$root instanceof HTMLInputElement)) {
+      return;
+    }
+    if (this.$root.type !== 'file') {
+      throw new ElementError('File upload: Form field must be an input of type `file`.');
+    }
+    this.config = mergeConfigs(FileUpload.defaults, config, normaliseDataset(FileUpload, this.$root.dataset));
+    this.i18n = new I18n(this.config.i18n, {
+      locale: closestAttributeValue(this.$root, 'lang')
+    });
+    this.$label = document.querySelector(`[for="${this.$root.id}"]`);
+    if (!this.$label) {
+      throw new ElementError({
+        component: FileUpload,
+        identifier: 'No label'
+      });
+    }
+    const $wrapper = document.createElement('div');
+    $wrapper.className = 'govuk-file-upload-wrapper';
+    const $button = document.createElement('button');
+    $button.className = 'govuk-button govuk-button--secondary govuk-file-upload__button';
+    $button.type = 'button';
+    $button.innerText = this.i18n.t('selectFilesButton');
+    $button.addEventListener('click', this.onClick.bind(this));
+    const $status = document.createElement('span');
+    $status.className = 'govuk-body govuk-file-upload__status';
+    $status.innerText = this.i18n.t('filesSelectedDefault');
+    $status.setAttribute('role', 'status');
+    $wrapper.insertAdjacentElement('beforeend', $button);
+    $wrapper.insertAdjacentElement('beforeend', $status);
+    this.$root.insertAdjacentElement('afterend', $wrapper);
+    $wrapper.insertAdjacentElement('afterbegin', this.$root);
+    this.$wrapper = $wrapper;
+    this.$button = $button;
+    this.$status = $status;
+    this.$root.setAttribute('tabindex', '-1');
+    this.updateDisabledState();
+    this.observeDisabledState();
+    this.$root.addEventListener('change', this.onChange.bind(this));
+    this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
+    document.addEventListener('dragenter', this.onDragEnter.bind(this));
+    document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+  }
+  onChange() {
+    if (!('files' in this.$root)) {
+      return;
+    }
+    if (!this.$root.files) {
+      return;
+    }
+    const fileCount = this.$root.files.length;
+    if (!this.$status || !this.i18n) {
+      return;
+    }
+    if (fileCount === 0) {
+      this.$status.innerText = this.i18n.t('filesSelectedDefault');
+    } else if (fileCount === 1) {
+      this.$status.innerText = this.$root.files[0].name;
+    } else {
+      this.$status.innerText = this.i18n.t('filesSelected', {
+        count: fileCount
+      });
+    }
+  }
+  onClick() {
+    if (this.$label instanceof HTMLElement) {
+      this.$label.click();
+    }
+  }
+
+  /**
+   * When a file is dragged over the container, show a visual indicator that a
+   * file can be dropped here.
+   *
+   * @param {DragEvent} event - the drag event
+   */
+  onDragEnter(event) {
+    console.log(event);
+    this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+  }
+  onDragLeaveOrDrop() {
+    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+  }
+  observeDisabledState() {
+    const observer = new MutationObserver(mutationList => {
+      for (const mutation of mutationList) {
+        console.log('mutation', mutation);
+        if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+          this.updateDisabledState();
+        }
+      }
+    });
+    observer.observe(this.$root, {
+      attributes: true
+    });
+  }
+  updateDisabledState() {
+    if (!(this.$root instanceof HTMLInputElement) || !(this.$button instanceof HTMLButtonElement)) {
+      return;
+    }
+    this.$button.disabled = this.$root.disabled;
+  }
+}
+
+/**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+/**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ *   many files have been selected
+ */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+  i18n: {
+    selectFilesButton: 'Choose file',
+    filesSelectedDefault: 'No file chosen',
+    filesSelected: {
+      one: '%{count} file chosen',
+      other: '%{count} files chosen'
+    }
+  }
+});
+FileUpload.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+
+export { FileUpload };
+//# sourceMappingURL=file-upload.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/fixtures.json b/packages/govuk-frontend/dist/govuk/components/file-upload/fixtures.json
index 4a36de2a3..74645e1bc 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/fixtures.json
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/fixtures.json
@@ -14,7 +14,75 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\">\n</div>"
+        },
+        {
+            "name": "allows multiple files",
+            "options": {
+                "id": "file-upload-1",
+                "name": "file-upload-1",
+                "label": {
+                    "text": "Upload a file"
+                },
+                "multiple": true
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "screenshot": false,
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\" multiple>\n</div>"
+        },
+        {
+            "name": "allows image files only",
+            "options": {
+                "id": "file-upload-1",
+                "name": "file-upload-1",
+                "label": {
+                    "text": "Upload a file"
+                },
+                "attributes": {
+                    "accept": "image/*"
+                }
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "screenshot": false,
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\" accept=\"image/*\">\n</div>"
+        },
+        {
+            "name": "allows direct media capture",
+            "options": {
+                "id": "file-upload-1",
+                "name": "file-upload-1",
+                "label": {
+                    "text": "Upload a file"
+                },
+                "attributes": {
+                    "capture": "user"
+                }
+            },
+            "hidden": false,
+            "description": "Currently only works on mobile devices.",
+            "previewLayoutModifiers": [],
+            "screenshot": false,
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\" capture=\"user\">\n</div>"
+        },
+        {
+            "name": "disabled",
+            "options": {
+                "id": "file-upload-1",
+                "name": "file-upload-1",
+                "label": {
+                    "text": "Upload a file"
+                },
+                "disabled": true
+            },
+            "hidden": false,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "screenshot": false,
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\" disabled>\n</div>"
         },
         {
             "name": "with hint text",
@@ -32,7 +100,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-2\">\n    Upload your photo\n  </label>\n  <div id=\"file-upload-2-hint\" class=\"govuk-hint\">\n    Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.\n  </div>\n  <input class=\"govuk-file-upload\" id=\"file-upload-2\" name=\"file-upload-2\" type=\"file\" aria-describedby=\"file-upload-2-hint\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-2\">\n    Upload your photo\n  </label>\n  <div id=\"file-upload-2-hint\" class=\"govuk-hint\">\n    Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.\n  </div>\n  <input class=\"govuk-file-upload\" id=\"file-upload-2\" name=\"file-upload-2\" type=\"file\" data-module=\"govuk-file-upload\" aria-describedby=\"file-upload-2-hint\">\n</div>"
         },
         {
             "name": "with error message and hint",
@@ -53,58 +121,80 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-3\">\n    Upload a file\n  </label>\n  <div id=\"file-upload-3-hint\" class=\"govuk-hint\">\n    Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.\n  </div>\n  <p id=\"file-upload-3-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message goes here\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-3\" name=\"file-upload-3\" type=\"file\" aria-describedby=\"file-upload-3-hint file-upload-3-error\">\n</div>"
+            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-3\">\n    Upload a file\n  </label>\n  <div id=\"file-upload-3-hint\" class=\"govuk-hint\">\n    Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.\n  </div>\n  <p id=\"file-upload-3-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message goes here\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-3\" name=\"file-upload-3\" type=\"file\" data-module=\"govuk-file-upload\" aria-describedby=\"file-upload-3-hint file-upload-3-error\">\n</div>"
         },
         {
-            "name": "with value",
+            "name": "with label as page heading",
             "options": {
-                "id": "file-upload-4",
-                "name": "file-upload-4",
-                "value": "C:\\fakepath\\myphoto.jpg",
+                "id": "file-upload-1",
+                "name": "file-upload-1",
                 "label": {
-                    "text": "Upload a photo"
+                    "text": "Upload a file",
+                    "classes": "govuk-label--l",
+                    "isPageHeading": true
                 }
             },
             "hidden": false,
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-4\">\n    Upload a photo\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-4\" name=\"file-upload-4\" type=\"file\" value=\"C:&#92;fakepath&#92;myphoto.jpg\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <h1 class=\"govuk-label-wrapper\">\n    <label class=\"govuk-label govuk-label--l\" for=\"file-upload-1\">\n      Upload a file\n    </label>\n  </h1>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\">\n</div>"
         },
         {
-            "name": "with label as page heading",
+            "name": "with optional form-group classes",
             "options": {
                 "id": "file-upload-1",
                 "name": "file-upload-1",
                 "label": {
-                    "text": "Upload a file",
-                    "classes": "govuk-label--l",
-                    "isPageHeading": true
+                    "text": "Upload a file"
+                },
+                "formGroup": {
+                    "classes": "extra-class"
                 }
             },
             "hidden": false,
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <h1 class=\"govuk-label-wrapper\">\n    <label class=\"govuk-label govuk-label--l\" for=\"file-upload-1\">\n      Upload a file\n    </label>\n  </h1>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\">\n</div>"
+            "html": "<div class=\"govuk-form-group extra-class\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\">\n</div>"
         },
         {
-            "name": "with optional form-group classes",
+            "name": "translated",
             "options": {
                 "id": "file-upload-1",
                 "name": "file-upload-1",
                 "label": {
-                    "text": "Upload a file"
+                    "text": "Llwythwch ffeil i fyny"
                 },
-                "formGroup": {
-                    "classes": "extra-class"
+                "multiple": true,
+                "selectFilesButtonText": "Dewiswch ffeil",
+                "filesSelectedDefaultText": "Dim ffeiliau wedi'u dewis",
+                "filesSelectedText": {
+                    "other": "%{count} ffeil wedi'u dewis",
+                    "one": "%{count} ffeil wedi'i dewis"
                 }
             },
             "hidden": false,
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group extra-class\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-1\">\n    Llwythwch ffeil i fyny\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" data-module=\"govuk-file-upload\" multiple data-i18n.select-files-button=\"Dewiswch ffeil\" data-i18n.files-selected-default=\"Dim ffeiliau wedi&#39;u dewis\" data-i18n.files-selected.other=\"%{count} ffeil wedi&#39;u dewis\" data-i18n.files-selected.one=\"%{count} ffeil wedi&#39;i dewis\">\n</div>"
+        },
+        {
+            "name": "with value",
+            "options": {
+                "id": "file-upload-4",
+                "name": "file-upload-4",
+                "value": "C:\\fakepath\\myphoto.jpg",
+                "label": {
+                    "text": "Upload a photo"
+                }
+            },
+            "hidden": true,
+            "description": "",
+            "previewLayoutModifiers": [],
+            "screenshot": false,
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-4\">\n    Upload a photo\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-4\" name=\"file-upload-4\" type=\"file\" data-module=\"govuk-file-upload\" value=\"C:&#92;fakepath&#92;myphoto.jpg\">\n</div>"
         },
         {
             "name": "attributes",
@@ -122,7 +212,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-attributes\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-attributes\" name=\"file-upload-attributes\" type=\"file\" accept=\".jpg, .jpeg, .png\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-attributes\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-attributes\" name=\"file-upload-attributes\" type=\"file\" data-module=\"govuk-file-upload\" accept=\".jpg, .jpeg, .png\">\n</div>"
         },
         {
             "name": "classes",
@@ -138,7 +228,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-classes\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload app-file-upload--custom-modifier\" id=\"file-upload-classes\" name=\"file-upload-classes\" type=\"file\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-classes\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload app-file-upload--custom-modifier\" id=\"file-upload-classes\" name=\"file-upload-classes\" type=\"file\" data-module=\"govuk-file-upload\">\n</div>"
         },
         {
             "name": "with describedBy",
@@ -154,7 +244,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-describedby\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-describedby\" name=\"file-upload-describedby\" type=\"file\" aria-describedby=\"test-target-element\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-describedby\">\n    Upload a file\n  </label>\n  <input class=\"govuk-file-upload\" id=\"file-upload-describedby\" name=\"file-upload-describedby\" type=\"file\" data-module=\"govuk-file-upload\" aria-describedby=\"test-target-element\">\n</div>"
         },
         {
             "name": "with hint and describedBy",
@@ -173,7 +263,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-hint-describedby\">\n    Upload a file\n  </label>\n  <div id=\"file-upload-hint-describedby-hint\" class=\"govuk-hint\">\n    Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.\n  </div>\n  <input class=\"govuk-file-upload\" id=\"file-upload-hint-describedby\" name=\"file-upload-hint-describedby\" type=\"file\" aria-describedby=\"test-target-element file-upload-hint-describedby-hint\">\n</div>"
+            "html": "<div class=\"govuk-form-group\">\n  <label class=\"govuk-label\" for=\"file-upload-hint-describedby\">\n    Upload a file\n  </label>\n  <div id=\"file-upload-hint-describedby-hint\" class=\"govuk-hint\">\n    Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.\n  </div>\n  <input class=\"govuk-file-upload\" id=\"file-upload-hint-describedby\" name=\"file-upload-hint-describedby\" type=\"file\" data-module=\"govuk-file-upload\" aria-describedby=\"test-target-element file-upload-hint-describedby-hint\">\n</div>"
         },
         {
             "name": "error",
@@ -191,7 +281,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-with-error\">\n    Upload a file\n  </label>\n  <p id=\"file-upload-with-error-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-with-error\" name=\"file-upload-with-error\" type=\"file\" aria-describedby=\"file-upload-with-error-error\">\n</div>"
+            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-with-error\">\n    Upload a file\n  </label>\n  <p id=\"file-upload-with-error-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-with-error\" name=\"file-upload-with-error\" type=\"file\" data-module=\"govuk-file-upload\" aria-describedby=\"file-upload-with-error-error\">\n</div>"
         },
         {
             "name": "with error and describedBy",
@@ -210,7 +300,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-error-describedby\">\n    Upload a file\n  </label>\n  <p id=\"file-upload-error-describedby-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-error-describedby\" name=\"file-upload-error-describedby\" type=\"file\" aria-describedby=\"test-target-element file-upload-error-describedby-error\">\n</div>"
+            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-error-describedby\">\n    Upload a file\n  </label>\n  <p id=\"file-upload-error-describedby-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-error-describedby\" name=\"file-upload-error-describedby\" type=\"file\" data-module=\"govuk-file-upload\" aria-describedby=\"test-target-element file-upload-error-describedby-error\">\n</div>"
         },
         {
             "name": "with error, describedBy and hint",
@@ -232,7 +322,7 @@
             "description": "",
             "previewLayoutModifiers": [],
             "screenshot": false,
-            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-error-describedby-hint\">\n    Upload a file\n  </label>\n  <div id=\"file-upload-error-describedby-hint-hint\" class=\"govuk-hint\">\n    hint\n  </div>\n  <p id=\"file-upload-error-describedby-hint-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-error-describedby-hint\" name=\"file-upload-error-describedby-hint\" type=\"file\" aria-describedby=\"test-target-element file-upload-error-describedby-hint-hint file-upload-error-describedby-hint-error\">\n</div>"
+            "html": "<div class=\"govuk-form-group govuk-form-group--error\">\n  <label class=\"govuk-label\" for=\"file-upload-error-describedby-hint\">\n    Upload a file\n  </label>\n  <div id=\"file-upload-error-describedby-hint-hint\" class=\"govuk-hint\">\n    hint\n  </div>\n  <p id=\"file-upload-error-describedby-hint-error\" class=\"govuk-error-message\">\n    <span class=\"govuk-visually-hidden\">Error:</span> Error message\n  </p>\n  <input class=\"govuk-file-upload govuk-file-upload--error\" id=\"file-upload-error-describedby-hint\" name=\"file-upload-error-describedby-hint\" type=\"file\" data-module=\"govuk-file-upload\" aria-describedby=\"test-target-element file-upload-error-describedby-hint-hint file-upload-error-describedby-hint-error\">\n</div>"
         }
     ]
 }
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/macro-options.json b/packages/govuk-frontend/dist/govuk/components/file-upload/macro-options.json
index c7608479a..deb9c3604 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/macro-options.json
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/macro-options.json
@@ -23,6 +23,12 @@
         "required": false,
         "description": "If `true`, file input will be disabled."
     },
+    {
+        "name": "multiple",
+        "type": "boolean",
+        "required": false,
+        "description": "If `true`, a user may select multiple files at the same time. The exact mechanism to do this differs depending on operating system."
+    },
     {
         "name": "describedBy",
         "type": "string",
@@ -110,6 +116,24 @@
             }
         ]
     },
+    {
+        "name": "selectFilesButtonText",
+        "type": "string",
+        "required": false,
+        "description": "The text of the button that opens the file picker. JavaScript enhanced version of the component only. Default is \"Choose file\"."
+    },
+    {
+        "name": "filesSelected",
+        "type": "object",
+        "required": false,
+        "description": "The text to display when multiple files has been chosen by the user. JavaScript enhanced version of the component only. The component will replace the `%{count}` placeholder with the number of files selected. This is a [pluralised list of messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend)."
+    },
+    {
+        "name": "filesSelectedDefault",
+        "type": "string",
+        "required": false,
+        "description": "The text to display when no file has been chosen by the user. JavaScript enhanced version of the component only. Default is \"No file chosen\"."
+    },
     {
         "name": "classes",
         "type": "string",
diff --git a/packages/govuk-frontend/dist/govuk/init.mjs b/packages/govuk-frontend/dist/govuk/init.mjs
index 9b087239b..6f762f4da 100644
--- a/packages/govuk-frontend/dist/govuk/init.mjs
+++ b/packages/govuk-frontend/dist/govuk/init.mjs
@@ -5,6 +5,7 @@ import { CharacterCount } from './components/character-count/character-count.mjs
 import { Checkboxes } from './components/checkboxes/checkboxes.mjs';
 import { ErrorSummary } from './components/error-summary/error-summary.mjs';
 import { ExitThisPage } from './components/exit-this-page/exit-this-page.mjs';
+import { FileUpload } from './components/file-upload/file-upload.mjs';
 import { Header } from './components/header/header.mjs';
 import { NotificationBanner } from './components/notification-banner/notification-banner.mjs';
 import { PasswordInput } from './components/password-input/password-input.mjs';
@@ -35,7 +36,7 @@ function initAll(config) {
     }
     return;
   }
-  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [ServiceNavigation], [SkipLink], [Tabs]];
+  const components = [[Accordion, config.accordion], [Button, config.button], [CharacterCount, config.characterCount], [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], [FileUpload, config.fileUpload], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], [Radios], [ServiceNavigation], [SkipLink], [Tabs]];
   const options = {
     scope: (_config$scope = config.scope) != null ? _config$scope : document,
     onError: config.onError
@@ -116,6 +117,7 @@ function createAll(Component, config, createAllOptions) {
  * @property {CharacterCountConfig} [characterCount] - Character Count config
  * @property {ErrorSummaryConfig} [errorSummary] - Error Summary config
  * @property {ExitThisPageConfig} [exitThisPage] - Exit This Page config
+ * @property {FileUploadConfig} [fileUpload] - File Upload config
  * @property {NotificationBannerConfig} [notificationBanner] - Notification Banner config
  * @property {PasswordInputConfig} [passwordInput] - Password input config
  */
@@ -130,6 +132,8 @@ function createAll(Component, config, createAllOptions) {
  * @typedef {import('./components/error-summary/error-summary.mjs').ErrorSummaryConfig} ErrorSummaryConfig
  * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageConfig} ExitThisPageConfig
  * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageTranslations} ExitThisPageTranslations
+ * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadConfig} FileUploadConfig
+ * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadTranslations} FileUploadTranslations
  * @typedef {import('./components/notification-banner/notification-banner.mjs').NotificationBannerConfig} NotificationBannerConfig
  * @typedef {import('./components/password-input/password-input.mjs').PasswordInputConfig} PasswordInputConfig
  */

Action run for 980b1c6fbc1bc170d03614183e989902a07b9a4f

selfthinker commented 1 month ago

I've started testing this today. But I've only tested with Dragon so far.

Unfortunately it doesn't work. Roughly 9 out of 10 times saying "click choose file" or "click button" only focused on the button but didn't open the file dialog. And weirdly 1 out of 10 times it did. But I didn't do anything differently between when it did and when it didn't.

When we've figured out why this is happening I will test again, including in other assistive technologies.

owenatgov commented 1 month ago

Rebased this whilst I do some local testing

edwardhorsford commented 1 month ago

Just in case it's helpful - we had a styled upload button on the passport service - which has presumably been tested by DAC multiple times.

querkmachine commented 1 month ago

@edwardhorsford Do you know if it was tested in Dragon NaturallySpeaking and, if so, whether it exhibited the same issues described by @selfthinker?

edwardhorsford commented 1 month ago

@edwardhorsford Do you know if it was tested in Dragon NaturallySpeaking and, if so, whether it exhibited the same issues described by @selfthinker?

I assume it was tested with it as DAC did the testing. But I don't recall more than that. You could try on the live service to see how it works now...

querkmachine commented 4 weeks ago

Adding aria-hidden to the input is apparently ignored by Chromium and raises an error in the console.

Blocked aria-hidden on a <input> element because the element that just received focus must not be hidden from assistive technology users. Avoid using aria-hidden on a focused element or its ancestor. Consider using the inert attribute instead, which will also prevent focus. For more details, see the aria-hidden section of the WAI-ARIA specification at https://w3c.github.io/aria/#aria-hidden.

tabindex="-1" alone probably suffices for stopping tabbing but I'm not sure if it can prevent keyboard navigation entirely, especially of the kind that screenreaders do which is on a level not easily detected by webpages.

selfthinker commented 4 weeks ago

DAC's solution was using the disabled attribute. But they were partly using it to show the uploaded file.