Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Use button element for entire input replacement #5609

Merged
merged 4 commits into from
Jan 23, 2025

Conversation

patrickpatrickpatrick
Copy link
Contributor

@patrickpatrickpatrick patrickpatrickpatrick commented Jan 15, 2025

What

Instead of replacing the native input with a collection of elements, replace the native input with a button that has the replacement encapsulated within it. Involves making a "pseudo-button" that has the same styling as a regular button.

  • Hide the file input with display: none and tabindex = '-1'.
  • Button replacement has pseudo elements to replicate same appearance of a button and label
  • Button replacement has aria-label with a value of the button contents
  • Adjustments to id of elements to ensure error summary focus will still work

Why

Fixes #5612

@patrickpatrickpatrick patrickpatrickpatrick changed the base branch from main to spike-enhanced-file-upload January 15, 2025 15:03
Copy link

github-actions bot commented Jan 15, 2025

📋 Stats

File sizes

File Size
dist/govuk-frontend-development.min.css 120.03 KiB
dist/govuk-frontend-development.min.js 46.92 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 100.74 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 94.64 KiB
packages/govuk-frontend/dist/govuk/all.mjs 1.32 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend-component.mjs 1.74 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.css 120.02 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.js 46.91 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB
packages/govuk-frontend/dist/govuk/init.mjs 7.5 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 88.77 KiB 44.45 KiB
accordion.mjs 26.58 KiB 13.41 KiB
button.mjs 9.09 KiB 3.78 KiB
character-count.mjs 25.39 KiB 10.9 KiB
checkboxes.mjs 7.81 KiB 3.42 KiB
error-summary.mjs 10.99 KiB 4.54 KiB
exit-this-page.mjs 20.2 KiB 10.34 KiB
file-upload.mjs 19.34 KiB 10.04 KiB
header.mjs 6.46 KiB 3.22 KiB
notification-banner.mjs 9.35 KiB 3.7 KiB
password-input.mjs 18.24 KiB 8.33 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 5d7fb7f

Copy link

github-actions bot commented Jan 15, 2025

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 5a65848c9..374ce22cc 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
@@ -14,13 +14,13 @@ function getBreakpoint(t) {
 
 function setFocus(t, e = {}) {
     var n;
-    const s = t.getAttribute("tabindex");
+    const i = t.getAttribute("tabindex");
 
     function onBlur() {
         var n;
-        null == (n = e.onBlur) || n.call(t), s || t.removeAttribute("tabindex")
+        null == (n = e.onBlur) || n.call(t), i || t.removeAttribute("tabindex")
     }
-    s || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
+    i || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
         t.addEventListener("blur", onBlur, {
             once: !0
         })
@@ -64,11 +64,11 @@ class ElementError extends GOVUKFrontendError {
         if ("object" == typeof t) {
             const {
                 component: n,
-                identifier: s,
-                element: i,
+                identifier: i,
+                element: s,
                 expectedType: o
             } = t;
-            e = s, e += i ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(n, e)
+            e = i, e += s ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(n, e)
         }
         super(e), this.name = "ElementError"
     }
@@ -118,57 +118,57 @@ class ConfigurableComponent extends GOVUKFrontendComponent {
     }
     constructor(e, n) {
         super(e), this._config = void 0;
-        const s = this.constructor;
-        if (void 0 === s.defaults) throw new ConfigError(formatErrorMessage(s, "Config passed as parameter into constructor but no defaults defined"));
-        const i = function(Component, t) {
+        const i = this.constructor;
+        if (void 0 === i.defaults) throw new ConfigError(formatErrorMessage(i, "Config passed as parameter into constructor but no defaults defined"));
+        const s = function(Component, t) {
             if (void 0 === Component.schema) throw new ConfigError(formatErrorMessage(Component, "Config passed as parameter into constructor but no schema defined"));
             const e = {};
-            for (const [n, s] of Object.entries(Component.schema.properties)) n in t && (e[n] = normaliseString(t[n], s)), "object" === (null == s ? void 0 : s.type) && (e[n] = extractConfigByNamespace(Component.schema, t, n));
+            for (const [n, i] of Object.entries(Component.schema.properties)) n in t && (e[n] = normaliseString(t[n], i)), "object" === (null == i ? void 0 : i.type) && (e[n] = extractConfigByNamespace(Component.schema, t, n));
             return e
-        }(s, this._$root.dataset);
-        this._config = mergeConfigs(s.defaults, null != n ? n : {}, this[t](i), i)
+        }(i, this._$root.dataset);
+        this._config = mergeConfigs(i.defaults, null != n ? n : {}, this[t](s), s)
     }
 }
 
 function normaliseString(t, e) {
     const n = t ? t.trim() : "";
-    let s, i = null == e ? void 0 : e.type;
-    switch (i || (["true", "false"].includes(n) && (i = "boolean"), n.length > 0 && isFinite(Number(n)) && (i = "number")), i) {
+    let i, s = null == e ? void 0 : e.type;
+    switch (s || (["true", "false"].includes(n) && (s = "boolean"), n.length > 0 && isFinite(Number(n)) && (s = "number")), s) {
         case "boolean":
-            s = "true" === n;
+            i = "true" === n;
             break;
         case "number":
-            s = Number(n);
+            i = Number(n);
             break;
         default:
-            s = t
+            i = t
     }
-    return s
+    return i
 }
 
 function mergeConfigs(...t) {
     const e = {};
     for (const n of t)
         for (const t of Object.keys(n)) {
-            const s = e[t],
-                i = n[t];
-            isObject(s) && isObject(i) ? e[t] = mergeConfigs(s, i) : e[t] = i
+            const i = e[t],
+                s = n[t];
+            isObject(i) && isObject(s) ? e[t] = mergeConfigs(i, s) : e[t] = s
         }
     return e
 }
 
 function extractConfigByNamespace(t, e, n) {
-    const s = t.properties[n];
-    if ("object" !== (null == s ? void 0 : s.type)) return;
-    const i = {
+    const i = t.properties[n];
+    if ("object" !== (null == i ? void 0 : i.type)) return;
+    const s = {
         [n]: {}
     };
     for (const [o, r] of Object.entries(e)) {
-        let t = i;
+        let t = s;
         const e = o.split(".");
-        for (const [s, i] of e.entries()) "object" == typeof t && (s < e.length - 1 ? (isObject(t[i]) || (t[i] = {}), t = t[i]) : o !== n && (t[i] = normaliseString(r)))
+        for (const [i, s] of e.entries()) "object" == typeof t && (i < e.length - 1 ? (isObject(t[s]) || (t[s] = {}), t = t[s]) : o !== n && (t[s] = normaliseString(r)))
     }
-    return i[n]
+    return s[n]
 }
 class I18n {
     constructor(t = {}, e = {}) {
@@ -179,8 +179,8 @@ class I18n {
         if (!t) throw new Error("i18n: lookup key missing");
         let n = this.translations[t];
         if ("number" == typeof(null == e ? void 0 : e.count) && "object" == typeof n) {
-            const s = n[this.getPluralSuffix(t, e.count)];
-            s && (n = s)
+            const i = n[this.getPluralSuffix(t, e.count)];
+            i && (n = i)
         }
         if ("string" == typeof n) {
             if (n.match(/%{(.\S+)}/)) {
@@ -193,9 +193,9 @@ class I18n {
     }
     replacePlaceholders(t, e) {
         const n = 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 t.replace(/%{(.\S+)}/g, (function(t, i) {
+            if (Object.prototype.hasOwnProperty.call(e, i)) {
+                const t = e[i];
                 return !1 === t || "number" != typeof t && "string" != typeof t ? "" : "number" == typeof t ? n ? n.format(t) : `${t}` : t
             }
             throw new Error(`i18n: no data found to replace ${t} placeholder in string`)
@@ -207,10 +207,10 @@ class I18n {
     getPluralSuffix(t, e) {
         if (e = Number(e), !isFinite(e)) return "other";
         const n = this.translations[t],
-            s = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
+            i = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
         if ("object" == typeof n) {
-            if (s in n) return s;
-            if ("other" in n) return console.warn(`i18n: Missing plural form ".${s}" for "${this.locale}" locale. Falling back to ".other".`), "other"
+            if (i in n) return i;
+            if ("other" in n) return console.warn(`i18n: Missing plural form ".${i}" for "${this.locale}" locale. Falling back to ".other".`), "other"
         }
         throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`)
     }
@@ -279,9 +279,9 @@ class Accordion extends ConfigurableComponent {
     }
     constructHeaderMarkup(t, e) {
         const n = t.querySelector(`.${this.sectionButtonClass}`),
-            s = t.querySelector(`.${this.sectionHeadingClass}`),
-            i = t.querySelector(`.${this.sectionSummaryClass}`);
-        if (!s) throw new ElementError({
+            i = t.querySelector(`.${this.sectionHeadingClass}`),
+            s = t.querySelector(`.${this.sectionSummaryClass}`);
+        if (!i) throw new ElementError({
             component: Accordion,
             identifier: `Section heading (\`.${this.sectionHeadingClass}\`)`
         });
@@ -302,14 +302,14 @@ class Accordion extends ConfigurableComponent {
         c.classList.add(this.sectionShowHideToggleFocusClass), l.appendChild(c);
         const h = document.createElement("span"),
             u = document.createElement("span");
-        if (u.classList.add(this.upChevronIconClass), c.appendChild(u), h.classList.add(this.sectionShowHideTextClass), c.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()), s) {
             const t = document.createElement("span"),
                 e = document.createElement("span");
             e.classList.add(this.sectionSummaryFocusClass), t.appendChild(e);
-            for (const n of Array.from(i.attributes)) t.setAttribute(n.name, n.value);
-            Array.from(i.childNodes).forEach((t => e.appendChild(t))), i.remove(), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
+            for (const n of Array.from(s.attributes)) t.setAttribute(n.name, n.value);
+            Array.from(s.childNodes).forEach((t => e.appendChild(t))), s.remove(), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
         }
-        o.appendChild(l), s.removeChild(n), s.appendChild(o)
+        o.appendChild(l), i.removeChild(n), i.appendChild(o)
     }
     onBeforeMatch(t) {
         const e = t.target;
@@ -329,23 +329,23 @@ class Accordion extends ConfigurableComponent {
     }
     setExpanded(t, e) {
         const n = e.querySelector(`.${this.upChevronIconClass}`),
-            s = e.querySelector(`.${this.sectionShowHideTextClass}`),
-            i = e.querySelector(`.${this.sectionButtonClass}`),
+            i = e.querySelector(`.${this.sectionShowHideTextClass}`),
+            s = e.querySelector(`.${this.sectionButtonClass}`),
             o = e.querySelector(`.${this.sectionContentClass}`);
         if (!o) throw new ElementError({
             component: Accordion,
             identifier: `Section content (\`<div class="${this.sectionContentClass}">\`)`
         });
-        if (!n || !s || !i) return;
+        if (!n || !i || !s) return;
         const r = t ? this.i18n.t("hideSection") : this.i18n.t("showSection");
-        s.textContent = r, i.setAttribute("aria-expanded", `${t}`);
+        i.textContent = r, s.setAttribute("aria-expanded", `${t}`);
         const a = [],
             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), n.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), e.classList.remove(this.sectionExpandedClass), n.classList.add(this.downChevronIconClass)), this.updateShowAllButton(this.areAllSectionsOpen())
+        a.push(h), s.setAttribute("aria-label", a.join(" , ")), t ? (o.removeAttribute("hidden"), e.classList.add(this.sectionExpandedClass), n.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), e.classList.remove(this.sectionExpandedClass), n.classList.add(this.downChevronIconClass)), this.updateShowAllButton(this.areAllSectionsOpen())
     }
     isExpanded(t) {
         return t.classList.contains(this.sectionExpandedClass)
@@ -365,7 +365,7 @@ class Accordion extends ConfigurableComponent {
         const n = this.getIdentifier(t);
         if (n) try {
             window.sessionStorage.setItem(n, e.toString())
-        } catch (s) {}
+        } catch (i) {}
     }
     setInitialState(t) {
         if (!this.config.rememberExpanded) return;
@@ -437,26 +437,26 @@ class CharacterCount extends ConfigurableComponent {
         }), e
     }
     constructor(t, e = {}) {
-        var n, s;
+        var n, i;
         super(t, e), this.$textarea = void 0, this.$visibleCountMessage = void 0, this.$screenReaderCountMessage = void 0, this.lastInputTimestamp = null, this.lastInputValue = "", this.valueChecker = null, this.i18n = void 0, this.maxLength = void 0;
-        const i = this.$root.querySelector(".govuk-js-character-count");
-        if (!(i instanceof HTMLTextAreaElement || i instanceof HTMLInputElement)) throw new ElementError({
+        const s = this.$root.querySelector(".govuk-js-character-count");
+        if (!(s instanceof HTMLTextAreaElement || s instanceof HTMLInputElement)) throw new ElementError({
             component: CharacterCount,
-            element: i,
+            element: s,
             expectedType: "HTMLTextareaElement or HTMLInputElement",
             identifier: "Form field (`.govuk-js-character-count`)"
         });
         const o = function(t, e) {
             const n = [];
-            for (const [s, i] of Object.entries(t)) {
+            for (const [i, s] of Object.entries(t)) {
                 const t = [];
-                if (Array.isArray(i)) {
+                if (Array.isArray(s)) {
                     for (const {
                             required: n,
-                            errorMessage: s
+                            errorMessage: i
                         }
-                        of i) n.every((t => !!e[t])) || t.push(s);
-                    "anyOf" !== s || i.length - t.length >= 1 || n.push(...t)
+                        of s) n.every((t => !!e[t])) || t.push(i);
+                    "anyOf" !== i || s.length - t.length >= 1 || n.push(...t)
                 }
             }
             return n
@@ -464,7 +464,7 @@ class CharacterCount extends ConfigurableComponent {
         if (o[0]) throw new ConfigError(formatErrorMessage(CharacterCount, o[0]));
         this.i18n = new I18n(this.config.i18n, {
             locale: closestAttributeValue(this.$root, "lang")
-        }), this.maxLength = null != (n = null != (s = this.config.maxwords) ? s : this.config.maxlength) ? n : 1 / 0, this.$textarea = i;
+        }), this.maxLength = null != (n = null != (i = this.config.maxwords) ? i : this.config.maxlength) ? n : 1 / 0, this.$textarea = s;
         const r = `${this.$textarea.id}-info`,
             a = document.getElementById(r);
         if (!a) throw new ElementError({
@@ -643,8 +643,8 @@ class ErrorSummary extends ConfigurableComponent {
         if (!e) return !1;
         const n = document.getElementById(e);
         if (!n) return !1;
-        const s = this.getAssociatedLegendOrLabel(n);
-        return !!s && (s.scrollIntoView(), n.focus({
+        const i = this.getAssociatedLegendOrLabel(n);
+        return !!i && (i.scrollIntoView(), n.focus({
             preventScroll: !0
         }), !0)
     }
@@ -656,10 +656,10 @@ class ErrorSummary extends ConfigurableComponent {
             if (e.length) {
                 const n = e[0];
                 if (t instanceof HTMLInputElement && ("checkbox" === t.type || "radio" === t.type)) return n;
-                const s = n.getBoundingClientRect().top,
-                    i = t.getBoundingClientRect();
-                if (i.height && window.innerHeight) {
-                    if (i.top + i.height - s < window.innerHeight / 2) return n
+                const i = n.getBoundingClientRect().top,
+                    s = t.getBoundingClientRect();
+                if (s.height && window.innerHeight) {
+                    if (s.top + s.height - i < window.innerHeight / 2) return n
                 }
             }
         }
@@ -686,8 +686,8 @@ class ExitThisPage extends ConfigurableComponent {
             identifier: "Button (`.govuk-exit-this-page__button`)"
         });
         this.i18n = new I18n(this.config.i18n), this.$button = n;
-        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))
+        const i = document.querySelector(".govuk-js-exit-this-page-skiplink");
+        i instanceof HTMLAnchorElement && (this.$skiplinkButton = i), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
     }
     initUpdateSpan() {
         this.$updateSpan = document.createElement("span"), this.$updateSpan.setAttribute("role", "status"), this.$updateSpan.className = "govuk-visually-hidden", this.$root.appendChild(this.$updateSpan)
@@ -750,16 +750,19 @@ ExitThisPage.moduleName = "govuk-exit-this-page", ExitThisPage.defaults = Object
 });
 class FileUpload extends ConfigurableComponent {
     constructor(t, e = {}) {
-        if (super(t, e), this.$wrapper = void 0, this.$button = void 0, this.$status = void 0, this.i18n = void 0, "file" !== this.$root.type) throw new ElementError(formatErrorMessage(FileUpload, "Form field must be an input of type `file`."));
-        this.i18n = new I18n(this.config.i18n, {
+        if (super(t, e), this.$wrapper = void 0, this.$button = void 0, this.$status = void 0, this.i18n = void 0, this.id = void 0, "file" !== this.$root.type) throw new ElementError(formatErrorMessage(FileUpload, "Form field must be an input of type `file`."));
+        if (!this.$root.id.length) throw new ElementError(formatErrorMessage(FileUpload, "Form field must specify an `id`."));
+        this.id = this.$root.id, this.i18n = new I18n(this.config.i18n, {
             locale: closestAttributeValue(this.$root, "lang")
-        }), this.$label = this.findLabel();
+        }), this.$label = this.findLabel(), this.$root.id = `${this.id}-input`;
         const n = document.createElement("div");
         n.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 i = document.createElement("span");
-        i.className = "govuk-body govuk-file-upload__status", i.innerText = this.i18n.t("filesSelectedDefault"), i.setAttribute("role", "status"), n.insertAdjacentElement("beforeend", s), n.insertAdjacentElement("beforeend", i), this.$root.insertAdjacentElement("afterend", n), n.insertAdjacentElement("afterbegin", this.$root), this.$wrapper = n, this.$button = s, this.$status = i, this.$root.setAttribute("tabindex", "-1"), this.updateDisabledState(), this.observeDisabledState(), this.$root.addEventListener("change", this.onChange.bind(this)), this.$announcements = document.createElement("span"), this.$announcements.classList.add("govuk-file-upload-announcements"), this.$announcements.classList.add("govuk-visually-hidden"), this.$announcements.setAttribute("aria-live", "assertive"), this.$wrapper.insertAdjacentElement("afterend", this.$announcements), this.$wrapper.addEventListener("drop", this.hideDropZone.bind(this)), document.addEventListener("dragenter", this.updateDropzoneVisibility.bind(this)), document.addEventListener("dragenter", (() => {
+        const i = document.createElement("button");
+        i.classList.add("govuk-file-upload__button"), i.type = "button", i.id = this.id;
+        const s = document.createElement("span");
+        s.className = "govuk-button govuk-button--secondary govuk-file-upload__pseudo-button", s.innerText = this.i18n.t("selectFilesButton"), s.setAttribute("aria-hidden", "true"), i.appendChild(s), i.addEventListener("click", this.onClick.bind(this));
+        const o = document.createElement("span");
+        o.className = "govuk-body govuk-file-upload__status", o.innerText = this.i18n.t("filesSelectedDefault"), o.setAttribute("aria-hidden", "true"), i.appendChild(o), i.setAttribute("aria-label", `${this.$label.innerText}, ${this.i18n.t("selectFilesButton")}, ${this.i18n.t("filesSelectedDefault")}`), n.insertAdjacentElement("beforeend", i), this.$root.insertAdjacentElement("afterend", n), this.$root.setAttribute("tabindex", "-1"), this.$root.setAttribute("aria-hidden", "true"), n.insertAdjacentElement("afterbegin", this.$root), this.$wrapper = n, this.$button = i, this.$status = o, this.$root.addEventListener("change", this.onChange.bind(this)), this.updateDisabledState(), this.observeDisabledState(), this.$root.addEventListener("change", this.onChange.bind(this)), this.$announcements = document.createElement("span"), this.$announcements.classList.add("govuk-file-upload-announcements"), this.$announcements.classList.add("govuk-visually-hidden"), this.$announcements.setAttribute("aria-live", "assertive"), this.$wrapper.insertAdjacentElement("afterend", this.$announcements), this.$wrapper.addEventListener("drop", this.hideDropZone.bind(this)), document.addEventListener("dragenter", this.updateDropzoneVisibility.bind(this)), document.addEventListener("dragenter", (() => {
             this.enteredAnotherElement = !0
         })), document.addEventListener("dragleave", (() => {
             this.enteredAnotherElement || this.hideDropZone(), this.enteredAnotherElement = !1
@@ -779,7 +782,7 @@ class FileUpload extends ConfigurableComponent {
         const t = this.$root.files.length;
         this.$status.innerText = 0 === t ? this.i18n.t("filesSelectedDefault") : 1 === t ? this.$root.files[0].name : this.i18n.t("filesSelected", {
             count: t
-        })
+        }), this.$button.setAttribute("aria-label", `${this.$label.innerText}, ${this.i18n.t("selectFilesButton")}, ${this.$status.innerText}`)
     }
     findLabel() {
         const t = document.querySelector(`label[for="${this.$root.id}"]`);
@@ -790,7 +793,7 @@ class FileUpload extends ConfigurableComponent {
         return t
     }
     onClick() {
-        this.$label.click()
+        this.$root.click()
     }
     observeDisabledState() {
         new MutationObserver((t => {
@@ -831,13 +834,13 @@ class Header extends GOVUKFrontendComponent {
             component: Header,
             identifier: 'Navigation button (`<button class="govuk-js-header-toggle">`) attribute (`aria-controls`)'
         });
-        const s = document.getElementById(n);
-        if (!s) throw new ElementError({
+        const i = document.getElementById(n);
+        if (!i) throw new ElementError({
             component: Header,
-            element: s,
+            element: i,
             identifier: `Navigation (\`<ul id="${n}">\`)`
         });
-        this.$menu = s, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = i, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("desktop");
@@ -880,19 +883,19 @@ class PasswordInput extends ConfigurableComponent {
             identifier: "Form field (`.govuk-js-password-input-input`)"
         });
         if ("password" !== n.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({
+        const i = this.$root.querySelector(".govuk-js-password-input-toggle");
+        if (!(i instanceof HTMLButtonElement)) throw new ElementError({
             component: PasswordInput,
-            element: s,
+            element: i,
             expectedType: "HTMLButtonElement",
             identifier: "Button (`.govuk-js-password-input-toggle`)"
         });
-        if ("button" !== s.type) throw new ElementError("Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.");
-        this.$input = n, this.$showHideButton = s, this.i18n = new I18n(this.config.i18n, {
+        if ("button" !== i.type) throw new ElementError("Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.");
+        this.$input = n, this.$showHideButton = i, this.i18n = new I18n(this.config.i18n, {
             locale: closestAttributeValue(this.$root, "lang")
         }), this.$showHideButton.removeAttribute("hidden");
-        const i = document.createElement("div");
-        i.className = "govuk-password-input__sr-status govuk-visually-hidden", i.setAttribute("aria-live", "polite"), this.$screenReaderStatusMessage = i, this.$input.insertAdjacentElement("afterend", i), this.$showHideButton.addEventListener("click", this.toggle.bind(this)), this.$input.form && this.$input.form.addEventListener("submit", (() => this.hide())), window.addEventListener("pageshow", (t => {
+        const s = document.createElement("div");
+        s.className = "govuk-password-input__sr-status govuk-visually-hidden", s.setAttribute("aria-live", "polite"), this.$screenReaderStatusMessage = s, this.$input.insertAdjacentElement("afterend", s), this.$showHideButton.addEventListener("click", this.toggle.bind(this)), this.$input.form && this.$input.form.addEventListener("submit", (() => this.hide())), window.addEventListener("pageshow", (t => {
             t.persisted && "password" !== this.$input.type && this.hide()
         })), this.hide()
     }
@@ -910,8 +913,8 @@ class PasswordInput extends ConfigurableComponent {
         this.$input.setAttribute("type", t);
         const e = "password" === t,
             n = e ? "show" : "hide",
-            s = e ? "passwordHidden" : "passwordShown";
-        this.$showHideButton.innerText = this.i18n.t(`${n}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${n}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${s}Announcement`)
+            i = e ? "passwordHidden" : "passwordShown";
+        this.$showHideButton.innerText = this.i18n.t(`${n}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${n}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${i}Announcement`)
     }
 }
 PasswordInput.moduleName = "govuk-password-input", PasswordInput.defaults = Object.freeze({
@@ -965,11 +968,11 @@ class Radios extends GOVUKFrontendComponent {
         const e = t.target;
         if (!(e instanceof HTMLInputElement) || "radio" !== e.type) return;
         const n = document.querySelectorAll('input[type="radio"][aria-controls]'),
-            s = e.form,
-            i = e.name;
+            i = e.form,
+            s = e.name;
         n.forEach((t => {
-            const e = t.form === s;
-            t.name === i && e && this.syncConditionalRevealWithInputState(t)
+            const e = t.form === i;
+            t.name === s && e && this.syncConditionalRevealWithInputState(t)
         }))
     }
 }
@@ -984,13 +987,13 @@ class ServiceNavigation extends GOVUKFrontendComponent {
             component: ServiceNavigation,
             identifier: 'Navigation button (`<button class="govuk-js-service-navigation-toggle">`) attribute (`aria-controls`)'
         });
-        const s = document.getElementById(n);
-        if (!s) throw new ElementError({
+        const i = document.getElementById(n);
+        if (!i) throw new ElementError({
             component: ServiceNavigation,
-            element: s,
+            element: i,
             identifier: `Navigation (\`<ul id="${n}">\`)`
         });
-        this.$menu = s, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = i, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("tablet");
@@ -1013,16 +1016,16 @@ class SkipLink extends GOVUKFrontendComponent {
         var e;
         super(t);
         const n = this.$root.hash,
-            s = null != (e = this.$root.getAttribute("href")) ? e : "";
-        let i;
+            i = null != (e = this.$root.getAttribute("href")) ? e : "";
+        let s;
         try {
-            i = new window.URL(this.$root.href)
+            s = new window.URL(this.$root.href)
         } catch (a) {
-            throw new ElementError(`Skip link: Target link (\`href="${s}"\`) is invalid`)
+            throw new ElementError(`Skip link: Target link (\`href="${i}"\`) is invalid`)
         }
-        if (i.origin !== window.location.origin || i.pathname !== window.location.pathname) return;
+        if (s.origin !== window.location.origin || s.pathname !== window.location.pathname) return;
         const o = getFragmentFromUrl(n);
-        if (!o) throw new ElementError(`Skip link: Target link (\`href="${s}"\`) has no hash fragment`);
+        if (!o) throw new ElementError(`Skip link: Target link (\`href="${i}"\`) has no hash fragment`);
         const r = document.getElementById(o);
         if (!r) throw new ElementError({
             component: SkipLink,
@@ -1050,16 +1053,16 @@ class Tabs extends GOVUKFrontendComponent {
         });
         this.$tabs = e, this.boundTabClick = this.onTabClick.bind(this), this.boundTabKeydown = this.onTabKeydown.bind(this), this.boundOnHashChange = this.onHashChange.bind(this);
         const n = this.$root.querySelector(".govuk-tabs__list"),
-            s = this.$root.querySelectorAll("li.govuk-tabs__list-item");
+            i = this.$root.querySelectorAll("li.govuk-tabs__list-item");
         if (!n) throw new ElementError({
             component: Tabs,
             identifier: 'List (`<ul class="govuk-tabs__list">`)'
         });
-        if (!s.length) throw new ElementError({
+        if (!i.length) throw new ElementError({
             component: Tabs,
             identifier: 'List items (`<li class="govuk-tabs__list-item">`)'
         });
-        this.$tabList = n, this.$tabListItems = s, this.setupResponsiveChecks()
+        this.$tabList = n, this.$tabListItems = i, this.setupResponsiveChecks()
     }
     setupResponsiveChecks() {
         const t = getBreakpoint("tablet");
@@ -1201,30 +1204,30 @@ function initAll(t) {
             [SkipLink],
             [Tabs]
         ],
-        s = {
+        i = {
             scope: null != (e = t.scope) ? e : document,
             onError: t.onError
         };
     n.forEach((([Component, t]) => {
-        createAll(Component, t, s)
+        createAll(Component, t, i)
     }))
 }
 
 function createAll(Component, t, e) {
-    let n, s = document;
-    var i;
-    "object" == typeof e && (s = null != (i = e.scope) ? i : s, n = e.onError);
-    "function" == typeof e && (n = e), e instanceof HTMLElement && (s = e);
-    const o = s.querySelectorAll(`[data-module="${Component.moduleName}"]`);
+    let n, i = document;
+    var s;
+    "object" == typeof e && (i = null != (s = e.scope) ? s : i, n = e.onError);
+    "function" == typeof e && (n = e), e instanceof HTMLElement && (i = e);
+    const o = i.querySelectorAll(`[data-module="${Component.moduleName}"]`);
     return isSupported() ? Array.from(o).map((e => {
         try {
             return void 0 !== t ? new Component(e, t) : new Component(e)
-        } catch (s) {
-            return n ? n(s, {
+        } catch (i) {
+            return n ? n(i, {
                 element: e,
                 component: Component,
                 config: t
-            }) : console.log(s), null
+            }) : console.log(i), null
         }
     })).filter(Boolean) : (n ? n(new SupportError, {
         component: Component,

Action run for 5d7fb7f

Copy link

github-actions bot commented Jan 15, 2025

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 f1b48d569..9417707ca 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
@@ -3397,7 +3397,7 @@ screen and (forced-colors:active) {
     background-color: #fff
 }
 
-.govuk-file-upload-wrapper--show-dropzone .govuk-file-upload__button,
+.govuk-file-upload-wrapper--show-dropzone .govuk-file-upload__pseudo-button,
 .govuk-file-upload-wrapper--show-dropzone .govuk-file-upload__status {
     pointer-events: none
 }
@@ -3413,7 +3413,7 @@ screen and (forced-colors:active) {
     opacity: 0
 }
 
-.govuk-file-upload__button {
+.govuk-file-upload__pseudo-button {
     width: auto;
     margin-bottom: 0;
     flex-grow: 0;
@@ -3425,6 +3425,53 @@ screen and (forced-colors:active) {
     margin-left: 10px
 }
 
+.govuk-file-upload__button:focus {
+    outline: none
+}
+
+.govuk-file-upload__button:focus .govuk-file-upload__pseudo-button {
+    outline: 3px solid transparent;
+    background-color: #fd0;
+    box-shadow: 0 2px 0 #0b0c0c
+}
+
+.govuk-file-upload__button:focus .govuk-file-upload__pseudo-button:hover {
+    border-color: #fd0;
+    outline: 3px solid transparent;
+    background-color: #f3f2f1;
+    box-shadow: inset 0 0 0 1px #fd0
+}
+
+.govuk-file-upload__button:active .govuk-file-upload__pseudo-button:hover {
+    background-color: #c2c2c1
+}
+
+.govuk-file-upload__button {
+    align-items: center;
+    display: flex;
+    padding: 0;
+    border: 0;
+    background-color: transparent
+}
+
+.govuk-file-upload:disabled+.govuk-file-upload__button {
+    pointer-events: none
+}
+
+.govuk-file-upload:disabled+.govuk-file-upload__button .govuk-file-upload__pseudo-button {
+    opacity: .5
+}
+
+.govuk-file-upload:disabled+.govuk-file-upload__button .govuk-file-upload__pseudo-button:hover {
+    background-color: #f3f2f1;
+    cursor: not-allowed
+}
+
+.govuk-file-upload:disabled+.govuk-file-upload__button .govuk-file-upload__pseudo-button:active {
+    top: 0;
+    box-shadow: 0 2px 0 #666
+}
+
 .govuk-footer {
     font-family: GDS Transport, arial, sans-serif;
     -webkit-font-smoothing: antialiased;

Action run for 5d7fb7f

Copy link

github-actions bot commented Jan 15, 2025

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 11771add6..a6d922cdd 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -1671,32 +1671,46 @@
       this.$button = void 0;
       this.$status = void 0;
       this.i18n = void 0;
+      this.id = void 0;
       if (this.$root.type !== 'file') {
         throw new ElementError(formatErrorMessage(FileUpload, 'Form field must be an input of type `file`.'));
       }
+      if (!this.$root.id.length) {
+        throw new ElementError(formatErrorMessage(FileUpload, 'Form field must specify an `id`.'));
+      }
+      this.id = this.$root.id;
       this.i18n = new I18n(this.config.i18n, {
         locale: closestAttributeValue(this.$root, 'lang')
       });
       this.$label = this.findLabel();
+      this.$root.id = `${this.id}-input`;
       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.classList.add('govuk-file-upload__button');
       $button.type = 'button';
-      $button.innerText = this.i18n.t('selectFilesButton');
+      $button.id = this.id;
+      const buttonSpan = document.createElement('span');
+      buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload__pseudo-button';
+      buttonSpan.innerText = this.i18n.t('selectFilesButton');
+      buttonSpan.setAttribute('aria-hidden', 'true');
+      $button.appendChild(buttonSpan);
       $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');
+      $status.setAttribute('aria-hidden', 'true');
+      $button.appendChild($status);
+      $button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`);
       $wrapper.insertAdjacentElement('beforeend', $button);
-      $wrapper.insertAdjacentElement('beforeend', $status);
       this.$root.insertAdjacentElement('afterend', $wrapper);
+      this.$root.setAttribute('tabindex', '-1');
+      this.$root.setAttribute('aria-hidden', 'true');
       $wrapper.insertAdjacentElement('afterbegin', this.$root);
       this.$wrapper = $wrapper;
       this.$button = $button;
       this.$status = $status;
-      this.$root.setAttribute('tabindex', '-1');
+      this.$root.addEventListener('change', this.onChange.bind(this));
       this.updateDisabledState();
       this.observeDisabledState();
       this.$root.addEventListener('change', this.onChange.bind(this));
@@ -1754,6 +1768,7 @@
           count: fileCount
         });
       }
+      this.$button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`);
     }
     findLabel() {
       const $label = document.querySelector(`label[for="${this.$root.id}"]`);
@@ -1766,7 +1781,7 @@
       return $label;
     }
     onClick() {
-      this.$label.click();
+      this.$root.click();
     }
     observeDisabledState() {
       const observer = new MutationObserver(mutationList => {
diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.mjs b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
index ebc802c20..62c861a22 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -1665,32 +1665,46 @@ class FileUpload extends ConfigurableComponent {
     this.$button = void 0;
     this.$status = void 0;
     this.i18n = void 0;
+    this.id = void 0;
     if (this.$root.type !== 'file') {
       throw new ElementError(formatErrorMessage(FileUpload, 'Form field must be an input of type `file`.'));
     }
+    if (!this.$root.id.length) {
+      throw new ElementError(formatErrorMessage(FileUpload, 'Form field must specify an `id`.'));
+    }
+    this.id = this.$root.id;
     this.i18n = new I18n(this.config.i18n, {
       locale: closestAttributeValue(this.$root, 'lang')
     });
     this.$label = this.findLabel();
+    this.$root.id = `${this.id}-input`;
     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.classList.add('govuk-file-upload__button');
     $button.type = 'button';
-    $button.innerText = this.i18n.t('selectFilesButton');
+    $button.id = this.id;
+    const buttonSpan = document.createElement('span');
+    buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload__pseudo-button';
+    buttonSpan.innerText = this.i18n.t('selectFilesButton');
+    buttonSpan.setAttribute('aria-hidden', 'true');
+    $button.appendChild(buttonSpan);
     $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');
+    $status.setAttribute('aria-hidden', 'true');
+    $button.appendChild($status);
+    $button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`);
     $wrapper.insertAdjacentElement('beforeend', $button);
-    $wrapper.insertAdjacentElement('beforeend', $status);
     this.$root.insertAdjacentElement('afterend', $wrapper);
+    this.$root.setAttribute('tabindex', '-1');
+    this.$root.setAttribute('aria-hidden', 'true');
     $wrapper.insertAdjacentElement('afterbegin', this.$root);
     this.$wrapper = $wrapper;
     this.$button = $button;
     this.$status = $status;
-    this.$root.setAttribute('tabindex', '-1');
+    this.$root.addEventListener('change', this.onChange.bind(this));
     this.updateDisabledState();
     this.observeDisabledState();
     this.$root.addEventListener('change', this.onChange.bind(this));
@@ -1748,6 +1762,7 @@ class FileUpload extends ConfigurableComponent {
         count: fileCount
       });
     }
+    this.$button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`);
   }
   findLabel() {
     const $label = document.querySelector(`label[for="${this.$root.id}"]`);
@@ -1760,7 +1775,7 @@ class FileUpload extends ConfigurableComponent {
     return $label;
   }
   onClick() {
-    this.$label.click();
+    this.$root.click();
   }
   observeDisabledState() {
     const observer = new MutationObserver(mutationList => {
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 34e781c51..4dc478173 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss
@@ -64,7 +64,7 @@
     border: $govuk-border-width-form-element dashed $govuk-input-border-colour;
     background-color: $govuk-body-background-colour;
 
-    .govuk-file-upload__button,
+    .govuk-file-upload__pseudo-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
@@ -85,7 +85,7 @@
     opacity: 0;
   }
 
-  .govuk-file-upload__button {
+  .govuk-file-upload__pseudo-button {
     width: auto;
     margin-bottom: 0;
     flex-grow: 0;
@@ -98,4 +98,51 @@
   }
 }
 
+.govuk-file-upload__button:focus {
+  outline: none;
+}
+
+.govuk-file-upload__button:focus .govuk-file-upload__pseudo-button {
+  outline: 3px solid transparent;
+  background-color: $govuk-focus-colour;
+  box-shadow: 0 2px 0 govuk-colour("black");
+}
+
+.govuk-file-upload__button:focus .govuk-file-upload__pseudo-button:hover {
+  border-color: $govuk-focus-colour;
+  outline: 3px solid transparent;
+  background-color: govuk-colour("light-grey");
+  box-shadow: inset 0 0 0 1px $govuk-focus-colour;
+}
+
+.govuk-file-upload__button:active .govuk-file-upload__pseudo-button:hover {
+  background-color: govuk-shade(govuk-colour("light-grey"), 20%);
+}
+
+.govuk-file-upload__button {
+  align-items: center;
+  display: flex;
+  padding: 0;
+  border: 0;
+  background-color: transparent;
+}
+
+.govuk-file-upload:disabled + .govuk-file-upload__button {
+  pointer-events: none;
+}
+
+.govuk-file-upload:disabled + .govuk-file-upload__button .govuk-file-upload__pseudo-button {
+  opacity: (0.5);
+
+  &:hover {
+    background-color: govuk-colour("light-grey");
+    cursor: not-allowed;
+  }
+
+  &:active {
+    top: 0;
+    box-shadow: 0 $govuk-border-width-form-element 0 govuk-shade(govuk-colour("white"), 60%); // s0
+  }
+}
+
 /*# 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
index 11a85cff2..905b3c3e4 100644
--- 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
@@ -498,32 +498,46 @@
       this.$button = void 0;
       this.$status = void 0;
       this.i18n = void 0;
+      this.id = void 0;
       if (this.$root.type !== 'file') {
         throw new ElementError(formatErrorMessage(FileUpload, 'Form field must be an input of type `file`.'));
       }
+      if (!this.$root.id.length) {
+        throw new ElementError(formatErrorMessage(FileUpload, 'Form field must specify an `id`.'));
+      }
+      this.id = this.$root.id;
       this.i18n = new I18n(this.config.i18n, {
         locale: closestAttributeValue(this.$root, 'lang')
       });
       this.$label = this.findLabel();
+      this.$root.id = `${this.id}-input`;
       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.classList.add('govuk-file-upload__button');
       $button.type = 'button';
-      $button.innerText = this.i18n.t('selectFilesButton');
+      $button.id = this.id;
+      const buttonSpan = document.createElement('span');
+      buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload__pseudo-button';
+      buttonSpan.innerText = this.i18n.t('selectFilesButton');
+      buttonSpan.setAttribute('aria-hidden', 'true');
+      $button.appendChild(buttonSpan);
       $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');
+      $status.setAttribute('aria-hidden', 'true');
+      $button.appendChild($status);
+      $button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`);
       $wrapper.insertAdjacentElement('beforeend', $button);
-      $wrapper.insertAdjacentElement('beforeend', $status);
       this.$root.insertAdjacentElement('afterend', $wrapper);
+      this.$root.setAttribute('tabindex', '-1');
+      this.$root.setAttribute('aria-hidden', 'true');
       $wrapper.insertAdjacentElement('afterbegin', this.$root);
       this.$wrapper = $wrapper;
       this.$button = $button;
       this.$status = $status;
-      this.$root.setAttribute('tabindex', '-1');
+      this.$root.addEventListener('change', this.onChange.bind(this));
       this.updateDisabledState();
       this.observeDisabledState();
       this.$root.addEventListener('change', this.onChange.bind(this));
@@ -581,6 +595,7 @@
           count: fileCount
         });
       }
+      this.$button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`);
     }
     findLabel() {
       const $label = document.querySelector(`label[for="${this.$root.id}"]`);
@@ -593,7 +608,7 @@
       return $label;
     }
     onClick() {
-      this.$label.click();
+      this.$root.click();
     }
     observeDisabledState() {
       const observer = new MutationObserver(mutationList => {
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
index b21914e73..72724da01 100644
--- 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
@@ -492,32 +492,46 @@ class FileUpload extends ConfigurableComponent {
     this.$button = void 0;
     this.$status = void 0;
     this.i18n = void 0;
+    this.id = void 0;
     if (this.$root.type !== 'file') {
       throw new ElementError(formatErrorMessage(FileUpload, 'Form field must be an input of type `file`.'));
     }
+    if (!this.$root.id.length) {
+      throw new ElementError(formatErrorMessage(FileUpload, 'Form field must specify an `id`.'));
+    }
+    this.id = this.$root.id;
     this.i18n = new I18n(this.config.i18n, {
       locale: closestAttributeValue(this.$root, 'lang')
     });
     this.$label = this.findLabel();
+    this.$root.id = `${this.id}-input`;
     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.classList.add('govuk-file-upload__button');
     $button.type = 'button';
-    $button.innerText = this.i18n.t('selectFilesButton');
+    $button.id = this.id;
+    const buttonSpan = document.createElement('span');
+    buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload__pseudo-button';
+    buttonSpan.innerText = this.i18n.t('selectFilesButton');
+    buttonSpan.setAttribute('aria-hidden', 'true');
+    $button.appendChild(buttonSpan);
     $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');
+    $status.setAttribute('aria-hidden', 'true');
+    $button.appendChild($status);
+    $button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`);
     $wrapper.insertAdjacentElement('beforeend', $button);
-    $wrapper.insertAdjacentElement('beforeend', $status);
     this.$root.insertAdjacentElement('afterend', $wrapper);
+    this.$root.setAttribute('tabindex', '-1');
+    this.$root.setAttribute('aria-hidden', 'true');
     $wrapper.insertAdjacentElement('afterbegin', this.$root);
     this.$wrapper = $wrapper;
     this.$button = $button;
     this.$status = $status;
-    this.$root.setAttribute('tabindex', '-1');
+    this.$root.addEventListener('change', this.onChange.bind(this));
     this.updateDisabledState();
     this.observeDisabledState();
     this.$root.addEventListener('change', this.onChange.bind(this));
@@ -575,6 +589,7 @@ class FileUpload extends ConfigurableComponent {
         count: fileCount
       });
     }
+    this.$button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`);
   }
   findLabel() {
     const $label = document.querySelector(`label[for="${this.$root.id}"]`);
@@ -587,7 +602,7 @@ class FileUpload extends ConfigurableComponent {
     return $label;
   }
   onClick() {
-    this.$label.click();
+    this.$root.click();
   }
   observeDisabledState() {
     const observer = new MutationObserver(mutationList => {
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
index c859f2ebd..3eeaf0c49 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
@@ -21,32 +21,46 @@ class FileUpload extends ConfigurableComponent {
     this.$button = void 0;
     this.$status = void 0;
     this.i18n = void 0;
+    this.id = void 0;
     if (this.$root.type !== 'file') {
       throw new ElementError(formatErrorMessage(FileUpload, 'Form field must be an input of type `file`.'));
     }
+    if (!this.$root.id.length) {
+      throw new ElementError(formatErrorMessage(FileUpload, 'Form field must specify an `id`.'));
+    }
+    this.id = this.$root.id;
     this.i18n = new I18n(this.config.i18n, {
       locale: closestAttributeValue(this.$root, 'lang')
     });
     this.$label = this.findLabel();
+    this.$root.id = `${this.id}-input`;
     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.classList.add('govuk-file-upload__button');
     $button.type = 'button';
-    $button.innerText = this.i18n.t('selectFilesButton');
+    $button.id = this.id;
+    const buttonSpan = document.createElement('span');
+    buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload__pseudo-button';
+    buttonSpan.innerText = this.i18n.t('selectFilesButton');
+    buttonSpan.setAttribute('aria-hidden', 'true');
+    $button.appendChild(buttonSpan);
     $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');
+    $status.setAttribute('aria-hidden', 'true');
+    $button.appendChild($status);
+    $button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`);
     $wrapper.insertAdjacentElement('beforeend', $button);
-    $wrapper.insertAdjacentElement('beforeend', $status);
     this.$root.insertAdjacentElement('afterend', $wrapper);
+    this.$root.setAttribute('tabindex', '-1');
+    this.$root.setAttribute('aria-hidden', 'true');
     $wrapper.insertAdjacentElement('afterbegin', this.$root);
     this.$wrapper = $wrapper;
     this.$button = $button;
     this.$status = $status;
-    this.$root.setAttribute('tabindex', '-1');
+    this.$root.addEventListener('change', this.onChange.bind(this));
     this.updateDisabledState();
     this.observeDisabledState();
     this.$root.addEventListener('change', this.onChange.bind(this));
@@ -104,6 +118,7 @@ class FileUpload extends ConfigurableComponent {
         count: fileCount
       });
     }
+    this.$button.setAttribute('aria-label', `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`);
   }
   findLabel() {
     const $label = document.querySelector(`label[for="${this.$root.id}"]`);
@@ -116,7 +131,7 @@ class FileUpload extends ConfigurableComponent {
     return $label;
   }
   onClick() {
-    this.$label.click();
+    this.$root.click();
   }
   observeDisabledState() {
     const observer = new MutationObserver(mutationList => {

Action run for 5d7fb7f

@patrickpatrickpatrick patrickpatrickpatrick changed the title use output Use button element for entire 'file upload' replacement Jan 16, 2025
@patrickpatrickpatrick patrickpatrickpatrick changed the title Use button element for entire 'file upload' replacement Use button element for entire input replacement Jan 16, 2025
@selfthinker
Copy link

selfthinker commented Jan 16, 2025

I've done some testing with assistive technologies. Details are in the testing spreadsheet (in the '16 Jan 2025' tab).
Here is a summary of my findings.

  • The focus style of the fake button is currently not the same as our standard secondary button.
  • There is no visible focus at all when using Windows High Contrast Mode or changing colours in Firefox.
  • I can confirm that this version of the component works with Dragon.
  • Most of the screen reader and browser combinations are pretty consistent, with a few exceptions.
  • There is no pause between the fake button text and the feedback area. I'd suggest to add a visually hidden comma between them. It's not that bad, but it also makes sense for those people who use their own styles or disable styles. When you disable styles there is not even a space. So, ideally also add a visually hidden space after the comma.
  • NVDA in Firefox never mentions the file name after a file has been selected. It only reads the label instead. I think that is because the screen reader focus is at the label for whatever reason. You can discover the file name when you navigate further.
  • VoiceOver on macOS in Safari seems to read the hidden file input after selecting a file. (It says: "no file selected, [label], [hint], file upload button".) It usually says "no file selected" (which is what the native file input says in Safari when no file is selected), but sometimes it happened that it read the file name instead. Interestingly, the native file input also says "no file selected" after selecting a file, so it's not a bug that we have introduced. It's possible the JS gets the values correctly but that the values that Safari provides are just incorrect.
  • It happened multiple times during testing with most screen readers that after selecting a file they either read "no file chosen" or the previously selected file. Could there be a race condition going on?

I suspect the only items from that list that are potentially gnarly are the two screen reader issues. We should definitely try to fix them for a bit. But the VO macOS / Safari issue has the lowest priority because of the pre-existing issue with the native input. It's worth trying to isolate the issue with NVDA to see where the issue comes from. It's possible that it's a bug in Firefox or NVDA.
If we cannot fix either of these issues, it's worth googling if there already is a bug report about that behaviour and if there isn't, report it ourselves.

@patrickpatrickpatrick
Copy link
Contributor Author

patrickpatrickpatrick commented Jan 17, 2025

Addressed a few comments:

  • focus state (including the focus state box shadow, style when the button is focus and hovered and style when the button is active and focused) for pseudo button now same as secondary button
  • added transparent outlines on focus for pseudo button for high contrast mode
  • visually hidden <span>, </span> added between pseudo button and text to ensure gap between announcement of each aspect of the button

@patrickpatrickpatrick
Copy link
Contributor Author

New visually hidden field for reading the contents of the button, I'm hoping this will fix the screen reader issues.

Copy link
Member

@romaricpascal romaricpascal left a comment

Choose a reason for hiding this comment

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

Almost there I think, that's neat! 🙌🏻

Spotted an issue with Voice Over and Safari due to the setting of the ID on the <button> (good spot that we'd need to do that for the Error Summary links), but nothing unfixable, I think.

@@ -62,7 +64,7 @@ describe('/components/file-upload', () => {

it('moves the file input inside of the wrapper element', async () => {
const inputElementParent = await page.$eval(
inputSelector,
buttonSelector,
Copy link
Member

Choose a reason for hiding this comment

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

issue Looks like this test was meant to check that the input was still inside the wrapper (and not removed from the DOM altogether). This is really so that the drag'n'drop works by dropping on the input itself, however, I'm not sure how much I'd trust Puppeteer's drag'n'drop tests to detect that we have removed the <input> by mistake, so I'd be keen to keep this test.

Suggested change
buttonSelector,
inputSelector,

@@ -93,35 +95,25 @@ describe('/components/file-upload', () => {
})

it('renders the button with default text', async () => {
const buttonElementText = await page.$eval(buttonSelector, (el) =>
el.innerHTML.trim()
const buttonElementText = await page.$eval(
Copy link
Member

Choose a reason for hiding this comment

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

suggestion Would it be worth checking that not only the elements this test looks for are on the page, but also inside the button itself?

Button replaces entire native file upload. This replacement has a within
it a "psuedo button" span that has the same focus, hover and active
behaviour as a secondary button.
Hide the button text entirely from screen reader and add button content
as `aria-label`. This should lead to more consistent reading out of the
button content.
- Tests now use the correct selector for when the input has been visually
replaced by the button.
- Added tests for button `aria-label`.
- Added test for clicking on different elements within button
Adjustments made to the the file upload so that in the event of an error
the correct part of the page can be linked to.
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-5609 January 21, 2025 16:36 Inactive
@selfthinker
Copy link

I haven't done a full test yet, but just some findings from a quick initial test:

  • The race condition still seems to be there.
  • The hint (which is connected via aria-describedby to the original file input) is not connected yet and doesn't get read out when the focus is on the button.
  • The label sometimes gets read out twice. I assume that's one from the aria-label and the other from the actual label. I need to test in other screen readers to confirm if that always happens, so safe to remove, or only sometimes. (I've so far only tested in NVDA/Chrome.)

@selfthinker
Copy link

Because we're using aria-label here and aria-labelledby in #5639 and neither in #5640, I wanted to compare how these three methods behave when the page is translated automatically. I tested with the Google Translate feature that comes with the Chrome browser. Other translation features may behave very differently.

The good news is that it translate even the text in attributes (so, works with aria-label).
But it treats the text in the attribute differently than the one on the label. I assume that is because one stands on its own and the other is within a "sentence" with comma-separated other texts.
When I translated into German, the label said "Laden Sie Ihr Foto hoch" but in the aria-label as well as the hidden span for the aria-labelledby it said "Foto hochladen". Both mean the same thing but could be confusing for screen reader users who can see that they are different.

So, #5640 works the most consistently, although the others work well enough.

@selfthinker
Copy link

Another thing to consider is users who use userstyles. That's a very small minority of users. While we don't necessarily need to ensure the page looks good with different CSS (or no CSS), it should at least be functional.

For those users the text on the button reads:

I guess it's mostly still functional for all three, but getting weird.

Copy link
Member

@romaricpascal romaricpascal left a comment

Choose a reason for hiding this comment

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

Ready to merge on the spike branch, the only remark I had likely affects the branch for the styling so we can update there 😊

Comment on lines +130 to +146
.govuk-file-upload:disabled + .govuk-file-upload__button {
pointer-events: none;
}

.govuk-file-upload:disabled + .govuk-file-upload__button .govuk-file-upload__pseudo-button {
opacity: (0.5);

&:hover {
background-color: govuk-colour("light-grey");
cursor: not-allowed;
}

&:active {
top: 0;
box-shadow: 0 $govuk-border-width-form-element 0 govuk-shade(govuk-colour("white"), 60%); // s0
}
}
Copy link
Member

Choose a reason for hiding this comment

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

@patrickpatrickpatrick One for the PR about the styles, but given we set the disabled attribute on the <button>, we should directly target the <button> rather than use the state of the <input>.

@patrickpatrickpatrick patrickpatrickpatrick merged commit 1a2852e into spike-enhanced-file-upload Jan 23, 2025
47 checks passed
@patrickpatrickpatrick patrickpatrickpatrick deleted the use-output branch January 23, 2025 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants