-
Notifications
You must be signed in to change notification settings - Fork 336
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[SPIKE] Label <button> with aria-labelledby (hidden text) #5639
Draft
patrickpatrickpatrick
wants to merge
2
commits into
spike-enhanced-file-upload
Choose a base branch
from
use-output-alt
base: spike-enhanced-file-upload
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Conversation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
patrickpatrickpatrick
changed the base branch from
main
to
spike-enhanced-file-upload
January 22, 2025 10:45
patrickpatrickpatrick
changed the base branch from
spike-enhanced-file-upload
to
use-output
January 22, 2025 10:45
patrickpatrickpatrick
changed the title
use output alt
Use aria-labelledby instead of aria-label on button.
Jan 22, 2025
📋 StatsFile sizes
Modules
View stats and visualisations on the review app Action run for 44f92b7 |
JavaScript changes to npm packagediff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
index 5a4b212e3..4bbe85bae 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}\`)`
});
@@ -296,20 +296,20 @@ class Accordion extends ConfigurableComponent {
r.classList.add(this.sectionHeadingTextClass), r.id = n.id;
const a = document.createElement("span");
a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), Array.from(n.childNodes).forEach((t => a.appendChild(t)));
- const c = document.createElement("span");
- c.classList.add(this.sectionShowHideToggleClass), c.setAttribute("data-nosnippet", "");
const l = document.createElement("span");
- l.classList.add(this.sectionShowHideToggleFocusClass), c.appendChild(l);
+ 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()), 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(c), 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 = [],
- 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), 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({
@@ -475,10 +475,10 @@ class CharacterCount extends ConfigurableComponent {
`${a.textContent}`.match(/^\s*$/) && (a.textContent = this.i18n.t("textareaDescription", {
count: this.maxLength
})), this.$textarea.insertAdjacentElement("afterend", a);
- const c = document.createElement("div");
- c.className = "govuk-character-count__sr-status govuk-visually-hidden", c.setAttribute("aria-live", "polite"), this.$screenReaderCountMessage = c, a.insertAdjacentElement("afterend", c);
const l = document.createElement("div");
- l.className = a.className, l.classList.add("govuk-character-count__status"), l.setAttribute("aria-hidden", "true"), this.$visibleCountMessage = l, a.insertAdjacentElement("afterend", l), a.classList.add("govuk-visually-hidden"), this.$textarea.removeAttribute("maxlength"), this.bindChangeEvents(), window.addEventListener("pageshow", (() => this.updateCountMessage())), this.updateCountMessage()
+ l.className = "govuk-character-count__sr-status govuk-visually-hidden", l.setAttribute("aria-live", "polite"), this.$screenReaderCountMessage = l, a.insertAdjacentElement("afterend", l);
+ const c = document.createElement("div");
+ c.className = a.className, c.classList.add("govuk-character-count__status"), c.setAttribute("aria-hidden", "true"), this.$visibleCountMessage = c, a.insertAdjacentElement("afterend", c), a.classList.add("govuk-visually-hidden"), this.$textarea.removeAttribute("maxlength"), this.bindChangeEvents(), window.addEventListener("pageshow", (() => this.updateCountMessage())), this.updateCountMessage()
}
bindChangeEvents() {
this.$textarea.addEventListener("keyup", (() => this.handleKeyUp())), this.$textarea.addEventListener("focus", (() => this.handleFocus())), this.$textarea.addEventListener("blur", (() => this.handleBlur()))
@@ -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)
@@ -748,6 +748,84 @@ 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.$visuallyHiddenStatus = 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.$root.id = `${this.id}-input`;
+ const n = document.createElement("div");
+ n.className = "govuk-file-upload-wrapper";
+ 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");
+ const r = document.createElement("span");
+ r.id = `${this.id}-visually-hidden`, r.className = "govuk-visually-hidden", r.innerText = `${this.$label.innerText}, ${this.i18n.t("selectFilesButton")}, ${this.i18n.t("filesSelectedDefault")}`, i.appendChild(s), i.appendChild(o), i.appendChild(r), i.setAttribute("aria-labelledby", r.id), 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.$visuallyHiddenStatus = r, 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
+ }))
+ }
+ updateDropzoneVisibility(t) {
+ t.target instanceof Node && (this.$wrapper.contains(t.target) ? t.dataTransfer && function(t) {
+ const e = 0 === t.types.length,
+ n = t.types.some((t => "Files" === t));
+ return e || n
+ }(t.dataTransfer) && (this.$wrapper.classList.contains("govuk-file-upload-wrapper--show-dropzone") || (this.$wrapper.classList.add("govuk-file-upload-wrapper--show-dropzone"), this.$announcements.innerText = this.i18n.t("dropZoneEntered"))) : this.$wrapper.classList.contains("govuk-file-upload-wrapper--show-dropzone") && this.hideDropZone())
+ }
+ hideDropZone() {
+ this.$wrapper.classList.remove("govuk-file-upload-wrapper--show-dropzone"), this.$announcements.innerText = this.i18n.t("dropZoneLeft")
+ }
+ onChange() {
+ 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.$visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t("selectFilesButton")}, ${this.$status.innerText}`
+ }
+ findLabel() {
+ const t = document.querySelector(`label[for="${this.$root.id}"]`);
+ if (!t) throw new ElementError({
+ component: FileUpload,
+ identifier: "No label"
+ });
+ return t
+ }
+ onClick() {
+ this.$root.click()
+ }
+ 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.$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"
+ },
+ dropZoneEntered: "Entered drop zone",
+ dropZoneLeft: "Left drop zone"
+ }
+}), 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;
@@ -758,13 +836,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");
@@ -807,19 +885,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()
}
@@ -837,8 +915,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({
@@ -892,11 +970,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)
}))
}
}
@@ -911,13 +989,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");
@@ -940,16 +1018,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,
@@ -977,16 +1055,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");
@@ -1119,6 +1197,7 @@ function initAll(t) {
[Checkboxes],
[ErrorSummary, t.errorSummary],
[ExitThisPage, t.exitThisPage],
+ [FileUpload, t.fileUpload],
[Header],
[NotificationBanner, t.notificationBanner],
[PasswordInput, t.passwordInput],
@@ -1127,30 +1206,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,
@@ -1167,6 +1246,7 @@ export {
ConfigurableComponent,
ErrorSummary,
ExitThisPage,
+ FileUpload,
Header,
NotificationBanner,
PasswordInput,
Action run for 44f92b7 |
Stylesheets changes to npm packagediff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
index 79597179a..468b31222 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
@@ -3389,6 +3389,94 @@ 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__pseudo-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__pseudo-button {
+ width: auto;
+ margin-bottom: 0;
+ flex-grow: 0;
+ flex-shrink: 0
+}
+
+.govuk-file-upload__status {
+ margin-bottom: 0;
+ 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 44f92b7 |
Rendered HTML changes to npm packagediff --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:\fakepath\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'u dewis" data-i18n.files-selected.other="%{count} ffeil wedi'u dewis" data-i18n.files-selected.one="%{count} ffeil wedi'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 44f92b7 |
Other changes to npm packagediff --git a/packages/govuk-frontend/dist/govuk/all.bundle.js b/packages/govuk-frontend/dist/govuk/all.bundle.js
index 7b511f0cb..c46de79b3 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -1654,6 +1654,219 @@
}
});
+ /**
+ * File upload component
+ *
+ * @preserve
+ * @augments ConfigurableComponent<FileUploadConfig,HTMLFileInputElement>
+ */
+ class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$wrapper = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.$visuallyHiddenStatus = 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.classList.add('govuk-file-upload__button');
+ $button.type = 'button';
+ $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('aria-hidden', 'true');
+ const $visuallyHiddenStatus = document.createElement('span');
+ $visuallyHiddenStatus.id = `${this.id}-visually-hidden`;
+ $visuallyHiddenStatus.className = 'govuk-visually-hidden';
+ $visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`;
+ $button.appendChild(buttonSpan);
+ $button.appendChild($status);
+ $button.appendChild($visuallyHiddenStatus);
+ $button.setAttribute('aria-labelledby', $visuallyHiddenStatus.id);
+ $wrapper.insertAdjacentElement('beforeend', $button);
+ 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.$visuallyHiddenStatus = $visuallyHiddenStatus;
+ 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 = true;
+ });
+ document.addEventListener('dragleave', () => {
+ if (!this.enteredAnotherElement) {
+ this.hideDropZone();
+ }
+ this.enteredAnotherElement = false;
+ });
+ }
+
+ /**
+ * Updates the visibility of the dropzone as users enters the various elements on the page
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ updateDropzoneVisibility(event) {
+ if (event.target instanceof Node) {
+ if (this.$wrapper.contains(event.target)) {
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+ }
+ }
+ } else {
+ if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.hideDropZone();
+ }
+ }
+ }
+ }
+ hideDropZone() {
+ this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneLeft');
+ }
+ onChange() {
+ const fileCount = this.$root.files.length;
+ 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
+ });
+ }
+ this.$visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`;
+ }
+ findLabel() {
+ const $label = document.querySelector(`label[for="${this.$root.id}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'No label'
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$root.click();
+ }
+ 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() {
+ 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'
+ },
+ dropZoneEntered: 'Entered drop zone',
+ dropZoneLeft: 'Left drop zone'
+ }
+ });
+ FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+ });
+ function isContainingFiles(dataTransfer) {
+ const hasNoTypesInfo = dataTransfer.types.length === 0;
+ const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+ return hasNoTypesInfo || isDraggingFiles;
+ }
+
+ /**
+ * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
+ */
+
+ /**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+ /**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ * many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ * when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ * when users left the drop zone while dragging
+ */
+
+ /**
+ * @typedef {import('../../common/configuration.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+
/**
* Header component
*
@@ -2436,7 +2649,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
@@ -2517,6 +2730,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
*/
@@ -2531,6 +2745,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
*/
@@ -2571,6 +2787,7 @@
exports.ConfigurableComponent = ConfigurableComponent;
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 536f4b22d..31ae495d4 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -1648,6 +1648,219 @@ ExitThisPage.schema = Object.freeze({
}
});
+/**
+ * File upload component
+ *
+ * @preserve
+ * @augments ConfigurableComponent<FileUploadConfig,HTMLFileInputElement>
+ */
+class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$wrapper = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.$visuallyHiddenStatus = 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.classList.add('govuk-file-upload__button');
+ $button.type = 'button';
+ $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('aria-hidden', 'true');
+ const $visuallyHiddenStatus = document.createElement('span');
+ $visuallyHiddenStatus.id = `${this.id}-visually-hidden`;
+ $visuallyHiddenStatus.className = 'govuk-visually-hidden';
+ $visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`;
+ $button.appendChild(buttonSpan);
+ $button.appendChild($status);
+ $button.appendChild($visuallyHiddenStatus);
+ $button.setAttribute('aria-labelledby', $visuallyHiddenStatus.id);
+ $wrapper.insertAdjacentElement('beforeend', $button);
+ 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.$visuallyHiddenStatus = $visuallyHiddenStatus;
+ 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 = true;
+ });
+ document.addEventListener('dragleave', () => {
+ if (!this.enteredAnotherElement) {
+ this.hideDropZone();
+ }
+ this.enteredAnotherElement = false;
+ });
+ }
+
+ /**
+ * Updates the visibility of the dropzone as users enters the various elements on the page
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ updateDropzoneVisibility(event) {
+ if (event.target instanceof Node) {
+ if (this.$wrapper.contains(event.target)) {
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+ }
+ }
+ } else {
+ if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.hideDropZone();
+ }
+ }
+ }
+ }
+ hideDropZone() {
+ this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneLeft');
+ }
+ onChange() {
+ const fileCount = this.$root.files.length;
+ 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
+ });
+ }
+ this.$visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`;
+ }
+ findLabel() {
+ const $label = document.querySelector(`label[for="${this.$root.id}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'No label'
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$root.click();
+ }
+ 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() {
+ 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'
+ },
+ dropZoneEntered: 'Entered drop zone',
+ dropZoneLeft: 'Left drop zone'
+ }
+});
+FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+});
+function isContainingFiles(dataTransfer) {
+ const hasNoTypesInfo = dataTransfer.types.length === 0;
+ const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+ return hasNoTypesInfo || isDraggingFiles;
+}
+
+/**
+ * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
+ */
+
+/**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+/**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ * many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ * when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ * when users left the drop zone while dragging
+ */
+
+/**
+ * @typedef {import('../../common/configuration.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+
/**
* Header component
*
@@ -2430,7 +2643,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
@@ -2511,6 +2724,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
*/
@@ -2525,6 +2739,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
*/
@@ -2557,5 +2773,5 @@ function createAll(Component, config, createAllOptions) {
* @property {OnErrorCallback<ComponentClass>} [onError] - callback function if error throw by component on init
*/
-export { Accordion, Button, CharacterCount, Checkboxes, GOVUKFrontendComponent as Component, ConfigurableComponent, ErrorSummary, ExitThisPage, Header, NotificationBanner, PasswordInput, Radios, ServiceNavigation, SkipLink, Tabs, createAll, initAll, isSupported, version };
+export { Accordion, Button, CharacterCount, Checkboxes, GOVUKFrontendComponent as Component, ConfigurableComponent, 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 0ebb2d609..704781daa 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..4dc478173 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,103 @@
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__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
+ 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__pseudo-button {
+ width: auto;
+ margin-bottom: 0;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+
+ .govuk-file-upload__status {
+ margin-bottom: 0;
+ margin-left: govuk-spacing(2);
+ }
+}
+
+.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
new file mode 100644
index 000000000..a86427a4d
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js
@@ -0,0 +1,700 @@
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = global.GOVUKFrontend || {}));
+})(this, (function (exports) { 'use strict';
+
+ function closestAttributeValue($element, attributeName) {
+ const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
+ return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
+ }
+
+ function isInitialised($root, moduleName) {
+ return $root instanceof HTMLElement && $root.hasAttribute(`data-${moduleName}-init`);
+ }
+
+ /**
+ * Checks if GOV.UK Frontend is supported on this page
+ *
+ * Some browsers will load and run our JavaScript but GOV.UK Frontend
+ * won't be supported.
+ *
+ * @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support
+ * @returns {boolean} Whether GOV.UK Frontend is supported on this page
+ */
+ function isSupported($scope = document.body) {
+ if (!$scope) {
+ return false;
+ }
+ return $scope.classList.contains('govuk-frontend-supported');
+ }
+ function isArray(option) {
+ return Array.isArray(option);
+ }
+ function isObject(option) {
+ return !!option && typeof option === 'object' && !isArray(option);
+ }
+ function formatErrorMessage(Component, message) {
+ return `${Component.moduleName}: ${message}`;
+ }
+ /**
+ * @typedef ComponentWithModuleName
+ * @property {string} moduleName - Name of the component
+ */
+
+ class GOVUKFrontendError extends Error {
+ constructor(...args) {
+ super(...args);
+ this.name = 'GOVUKFrontendError';
+ }
+ }
+ class SupportError extends GOVUKFrontendError {
+ /**
+ * Checks if GOV.UK Frontend is supported on this page
+ *
+ * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
+ */
+ constructor($scope = document.body) {
+ const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
+ super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
+ this.name = 'SupportError';
+ }
+ }
+ class ConfigError extends GOVUKFrontendError {
+ constructor(...args) {
+ super(...args);
+ this.name = 'ConfigError';
+ }
+ }
+ class ElementError extends GOVUKFrontendError {
+ constructor(messageOrOptions) {
+ let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
+ if (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;
+
+ const configOverride = Symbol.for('configOverride');
+ class ConfigurableComponent extends GOVUKFrontendComponent {
+ [configOverride](param) {
+ return {};
+ }
+
+ /**
+ * Returns the root element of the component
+ *
+ * @protected
+ * @returns {ConfigurationType} - the root element of component
+ */
+ get config() {
+ return this._config;
+ }
+ constructor($root, config) {
+ super($root);
+ this._config = void 0;
+ const childConstructor = this.constructor;
+ if (typeof childConstructor.defaults === 'undefined') {
+ throw new ConfigError(formatErrorMessage(childConstructor, 'Config passed as parameter into constructor but no defaults defined'));
+ }
+ const datasetConfig = normaliseDataset(childConstructor, this._$root.dataset);
+ this._config = mergeConfigs(childConstructor.defaults, config != null ? config : {}, this[configOverride](datasetConfig), datasetConfig);
+ }
+ }
+ function normaliseString(value, property) {
+ const trimmedValue = value ? value.trim() : '';
+ let output;
+ let outputType = property == null ? void 0 : property.type;
+ if (!outputType) {
+ if (['true', 'false'].includes(trimmedValue)) {
+ outputType = 'boolean';
+ }
+ if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+ outputType = 'number';
+ }
+ }
+ switch (outputType) {
+ case 'boolean':
+ output = trimmedValue === 'true';
+ break;
+ case 'number':
+ output = Number(trimmedValue);
+ break;
+ default:
+ output = value;
+ }
+ return output;
+ }
+ function normaliseDataset(Component, dataset) {
+ if (typeof Component.schema === 'undefined') {
+ throw new ConfigError(formatErrorMessage(Component, 'Config passed as parameter into constructor but no schema defined'));
+ }
+ 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.schema, dataset, field);
+ }
+ }
+ return out;
+ }
+ function mergeConfigs(...configObjects) {
+ const formattedConfigObject = {};
+ for (const configObject of configObjects) {
+ for (const key of Object.keys(configObject)) {
+ const option = formattedConfigObject[key];
+ const override = configObject[key];
+ if (isObject(option) && isObject(override)) {
+ formattedConfigObject[key] = mergeConfigs(option, override);
+ } else {
+ formattedConfigObject[key] = override;
+ }
+ }
+ }
+ return formattedConfigObject;
+ }
+ function extractConfigByNamespace(schema, dataset, namespace) {
+ const property = schema.properties[namespace];
+ if ((property == null ? void 0 : property.type) !== 'object') {
+ return;
+ }
+ const newObject = {
+ [namespace]: ({})
+ };
+ for (const [key, value] of Object.entries(dataset)) {
+ let current = newObject;
+ const keyParts = key.split('.');
+ for (const [index, name] of keyParts.entries()) {
+ if (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];
+ }
+ /**
+ * 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
+ */
+ /**
+ * @template {ObjectNested} [ConfigurationType={}]
+ * @typedef ChildClass
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+ * @property {Schema} [schema] - The schema of the component configuration
+ * @property {ConfigurationType} [defaults] - The default values of the configuration of the component
+ */
+ /**
+ * @template {ObjectNested} [ConfigurationType={}]
+ * @typedef {typeof GOVUKFrontendComponent & ChildClass<ConfigurationType>} ChildClassConstructor<ConfigurationType>
+ */
+
+ 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
+ * @augments ConfigurableComponent<FileUploadConfig,HTMLFileInputElement>
+ */
+ class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$wrapper = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.$visuallyHiddenStatus = 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.classList.add('govuk-file-upload__button');
+ $button.type = 'button';
+ $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('aria-hidden', 'true');
+ const $visuallyHiddenStatus = document.createElement('span');
+ $visuallyHiddenStatus.id = `${this.id}-visually-hidden`;
+ $visuallyHiddenStatus.className = 'govuk-visually-hidden';
+ $visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`;
+ $button.appendChild(buttonSpan);
+ $button.appendChild($status);
+ $button.appendChild($visuallyHiddenStatus);
+ $button.setAttribute('aria-labelledby', $visuallyHiddenStatus.id);
+ $wrapper.insertAdjacentElement('beforeend', $button);
+ 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.$visuallyHiddenStatus = $visuallyHiddenStatus;
+ 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 = true;
+ });
+ document.addEventListener('dragleave', () => {
+ if (!this.enteredAnotherElement) {
+ this.hideDropZone();
+ }
+ this.enteredAnotherElement = false;
+ });
+ }
+
+ /**
+ * Updates the visibility of the dropzone as users enters the various elements on the page
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ updateDropzoneVisibility(event) {
+ if (event.target instanceof Node) {
+ if (this.$wrapper.contains(event.target)) {
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+ }
+ }
+ } else {
+ if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.hideDropZone();
+ }
+ }
+ }
+ }
+ hideDropZone() {
+ this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneLeft');
+ }
+ onChange() {
+ const fileCount = this.$root.files.length;
+ 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
+ });
+ }
+ this.$visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`;
+ }
+ findLabel() {
+ const $label = document.querySelector(`label[for="${this.$root.id}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'No label'
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$root.click();
+ }
+ 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() {
+ 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'
+ },
+ dropZoneEntered: 'Entered drop zone',
+ dropZoneLeft: 'Left drop zone'
+ }
+ });
+ FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+ });
+ function isContainingFiles(dataTransfer) {
+ const hasNoTypesInfo = dataTransfer.types.length === 0;
+ const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+ return hasNoTypesInfo || isDraggingFiles;
+ }
+
+ /**
+ * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
+ */
+
+ /**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+ /**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ * many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ * when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ * when users left the drop zone while dragging
+ */
+
+ /**
+ * @typedef {import('../../common/configuration.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+
+ 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..336e06dbf
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs
@@ -0,0 +1,692 @@
+function closestAttributeValue($element, attributeName) {
+ const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
+ return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
+}
+
+function isInitialised($root, moduleName) {
+ return $root instanceof HTMLElement && $root.hasAttribute(`data-${moduleName}-init`);
+}
+
+/**
+ * Checks if GOV.UK Frontend is supported on this page
+ *
+ * Some browsers will load and run our JavaScript but GOV.UK Frontend
+ * won't be supported.
+ *
+ * @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support
+ * @returns {boolean} Whether GOV.UK Frontend is supported on this page
+ */
+function isSupported($scope = document.body) {
+ if (!$scope) {
+ return false;
+ }
+ return $scope.classList.contains('govuk-frontend-supported');
+}
+function isArray(option) {
+ return Array.isArray(option);
+}
+function isObject(option) {
+ return !!option && typeof option === 'object' && !isArray(option);
+}
+function formatErrorMessage(Component, message) {
+ return `${Component.moduleName}: ${message}`;
+}
+/**
+ * @typedef ComponentWithModuleName
+ * @property {string} moduleName - Name of the component
+ */
+
+class GOVUKFrontendError extends Error {
+ constructor(...args) {
+ super(...args);
+ this.name = 'GOVUKFrontendError';
+ }
+}
+class SupportError extends GOVUKFrontendError {
+ /**
+ * Checks if GOV.UK Frontend is supported on this page
+ *
+ * @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
+ */
+ constructor($scope = document.body) {
+ const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
+ super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
+ this.name = 'SupportError';
+ }
+}
+class ConfigError extends GOVUKFrontendError {
+ constructor(...args) {
+ super(...args);
+ this.name = 'ConfigError';
+ }
+}
+class ElementError extends GOVUKFrontendError {
+ constructor(messageOrOptions) {
+ let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
+ if (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;
+
+const configOverride = Symbol.for('configOverride');
+class ConfigurableComponent extends GOVUKFrontendComponent {
+ [configOverride](param) {
+ return {};
+ }
+
+ /**
+ * Returns the root element of the component
+ *
+ * @protected
+ * @returns {ConfigurationType} - the root element of component
+ */
+ get config() {
+ return this._config;
+ }
+ constructor($root, config) {
+ super($root);
+ this._config = void 0;
+ const childConstructor = this.constructor;
+ if (typeof childConstructor.defaults === 'undefined') {
+ throw new ConfigError(formatErrorMessage(childConstructor, 'Config passed as parameter into constructor but no defaults defined'));
+ }
+ const datasetConfig = normaliseDataset(childConstructor, this._$root.dataset);
+ this._config = mergeConfigs(childConstructor.defaults, config != null ? config : {}, this[configOverride](datasetConfig), datasetConfig);
+ }
+}
+function normaliseString(value, property) {
+ const trimmedValue = value ? value.trim() : '';
+ let output;
+ let outputType = property == null ? void 0 : property.type;
+ if (!outputType) {
+ if (['true', 'false'].includes(trimmedValue)) {
+ outputType = 'boolean';
+ }
+ if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+ outputType = 'number';
+ }
+ }
+ switch (outputType) {
+ case 'boolean':
+ output = trimmedValue === 'true';
+ break;
+ case 'number':
+ output = Number(trimmedValue);
+ break;
+ default:
+ output = value;
+ }
+ return output;
+}
+function normaliseDataset(Component, dataset) {
+ if (typeof Component.schema === 'undefined') {
+ throw new ConfigError(formatErrorMessage(Component, 'Config passed as parameter into constructor but no schema defined'));
+ }
+ 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.schema, dataset, field);
+ }
+ }
+ return out;
+}
+function mergeConfigs(...configObjects) {
+ const formattedConfigObject = {};
+ for (const configObject of configObjects) {
+ for (const key of Object.keys(configObject)) {
+ const option = formattedConfigObject[key];
+ const override = configObject[key];
+ if (isObject(option) && isObject(override)) {
+ formattedConfigObject[key] = mergeConfigs(option, override);
+ } else {
+ formattedConfigObject[key] = override;
+ }
+ }
+ }
+ return formattedConfigObject;
+}
+function extractConfigByNamespace(schema, dataset, namespace) {
+ const property = schema.properties[namespace];
+ if ((property == null ? void 0 : property.type) !== 'object') {
+ return;
+ }
+ const newObject = {
+ [namespace]: ({})
+ };
+ for (const [key, value] of Object.entries(dataset)) {
+ let current = newObject;
+ const keyParts = key.split('.');
+ for (const [index, name] of keyParts.entries()) {
+ if (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];
+}
+/**
+ * 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
+ */
+/**
+ * @template {ObjectNested} [ConfigurationType={}]
+ * @typedef ChildClass
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+ * @property {Schema} [schema] - The schema of the component configuration
+ * @property {ConfigurationType} [defaults] - The default values of the configuration of the component
+ */
+/**
+ * @template {ObjectNested} [ConfigurationType={}]
+ * @typedef {typeof GOVUKFrontendComponent & ChildClass<ConfigurationType>} ChildClassConstructor<ConfigurationType>
+ */
+
+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
+ * @augments ConfigurableComponent<FileUploadConfig,HTMLFileInputElement>
+ */
+class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$wrapper = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.$visuallyHiddenStatus = 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.classList.add('govuk-file-upload__button');
+ $button.type = 'button';
+ $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('aria-hidden', 'true');
+ const $visuallyHiddenStatus = document.createElement('span');
+ $visuallyHiddenStatus.id = `${this.id}-visually-hidden`;
+ $visuallyHiddenStatus.className = 'govuk-visually-hidden';
+ $visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`;
+ $button.appendChild(buttonSpan);
+ $button.appendChild($status);
+ $button.appendChild($visuallyHiddenStatus);
+ $button.setAttribute('aria-labelledby', $visuallyHiddenStatus.id);
+ $wrapper.insertAdjacentElement('beforeend', $button);
+ 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.$visuallyHiddenStatus = $visuallyHiddenStatus;
+ 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 = true;
+ });
+ document.addEventListener('dragleave', () => {
+ if (!this.enteredAnotherElement) {
+ this.hideDropZone();
+ }
+ this.enteredAnotherElement = false;
+ });
+ }
+
+ /**
+ * Updates the visibility of the dropzone as users enters the various elements on the page
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ updateDropzoneVisibility(event) {
+ if (event.target instanceof Node) {
+ if (this.$wrapper.contains(event.target)) {
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+ }
+ }
+ } else {
+ if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.hideDropZone();
+ }
+ }
+ }
+ }
+ hideDropZone() {
+ this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneLeft');
+ }
+ onChange() {
+ const fileCount = this.$root.files.length;
+ 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
+ });
+ }
+ this.$visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`;
+ }
+ findLabel() {
+ const $label = document.querySelector(`label[for="${this.$root.id}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'No label'
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$root.click();
+ }
+ 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() {
+ 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'
+ },
+ dropZoneEntered: 'Entered drop zone',
+ dropZoneLeft: 'Left drop zone'
+ }
+});
+FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+});
+function isContainingFiles(dataTransfer) {
+ const hasNoTypesInfo = dataTransfer.types.length === 0;
+ const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+ return hasNoTypesInfo || isDraggingFiles;
+}
+
+/**
+ * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
+ */
+
+/**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+/**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ * many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ * when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ * when users left the drop zone while dragging
+ */
+
+/**
+ * @typedef {import('../../common/configuration.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+
+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..2b29a7770
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
@@ -0,0 +1,221 @@
+import { closestAttributeValue } from '../../common/closest-attribute-value.mjs';
+import { ConfigurableComponent } from '../../common/configuration.mjs';
+import { formatErrorMessage } from '../../common/index.mjs';
+import { ElementError } from '../../errors/index.mjs';
+import { I18n } from '../../i18n.mjs';
+
+/**
+ * File upload component
+ *
+ * @preserve
+ * @augments ConfigurableComponent<FileUploadConfig,HTMLFileInputElement>
+ */
+class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$wrapper = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.$visuallyHiddenStatus = 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.classList.add('govuk-file-upload__button');
+ $button.type = 'button';
+ $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('aria-hidden', 'true');
+ const $visuallyHiddenStatus = document.createElement('span');
+ $visuallyHiddenStatus.id = `${this.id}-visually-hidden`;
+ $visuallyHiddenStatus.className = 'govuk-visually-hidden';
+ $visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.i18n.t('filesSelectedDefault')}`;
+ $button.appendChild(buttonSpan);
+ $button.appendChild($status);
+ $button.appendChild($visuallyHiddenStatus);
+ $button.setAttribute('aria-labelledby', $visuallyHiddenStatus.id);
+ $wrapper.insertAdjacentElement('beforeend', $button);
+ 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.$visuallyHiddenStatus = $visuallyHiddenStatus;
+ 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 = true;
+ });
+ document.addEventListener('dragleave', () => {
+ if (!this.enteredAnotherElement) {
+ this.hideDropZone();
+ }
+ this.enteredAnotherElement = false;
+ });
+ }
+
+ /**
+ * Updates the visibility of the dropzone as users enters the various elements on the page
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ updateDropzoneVisibility(event) {
+ if (event.target instanceof Node) {
+ if (this.$wrapper.contains(event.target)) {
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+ }
+ }
+ } else {
+ if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+ this.hideDropZone();
+ }
+ }
+ }
+ }
+ hideDropZone() {
+ this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+ this.$announcements.innerText = this.i18n.t('dropZoneLeft');
+ }
+ onChange() {
+ const fileCount = this.$root.files.length;
+ 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
+ });
+ }
+ this.$visuallyHiddenStatus.innerText = `${this.$label.innerText}, ${this.i18n.t('selectFilesButton')}, ${this.$status.innerText}`;
+ }
+ findLabel() {
+ const $label = document.querySelector(`label[for="${this.$root.id}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'No label'
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$root.click();
+ }
+ 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() {
+ 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'
+ },
+ dropZoneEntered: 'Entered drop zone',
+ dropZoneLeft: 'Left drop zone'
+ }
+});
+FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+});
+function isContainingFiles(dataTransfer) {
+ const hasNoTypesInfo = dataTransfer.types.length === 0;
+ const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+ return hasNoTypesInfo || isDraggingFiles;
+}
+
+/**
+ * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
+ */
+
+/**
+ * File upload config
+ *
+ * @see {@link FileUpload.defaults}
+ * @typedef {object} FileUploadConfig
+ * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations
+ */
+
+/**
+ * File upload translations
+ *
+ * @see {@link FileUpload.defaults.i18n}
+ * @typedef {object} FileUploadTranslations
+ *
+ * Messages used by the component
+ * @property {string} [selectFiles] - Text of button that opens file browser
+ * @property {TranslationPluralForms} [filesSelected] - Text indicating how
+ * many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ * when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ * when users left the drop zone while dragging
+ */
+
+/**
+ * @typedef {import('../../common/configuration.mjs').Schema} Schema
+ * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
+ */
+
+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:\fakepath\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'u dewis\" data-i18n.files-selected.other=\"%{count} ffeil wedi'u dewis\" data-i18n.files-selected.one=\"%{count} ffeil wedi'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:\fakepath\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 7bc77bdab..46ec13e4b 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 44f92b7 |
romaricpascal
changed the title
Use aria-labelledby instead of aria-label on button.
[SPIKE] Label <button> with aria-labelledby (hidden text)
Jan 27, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Use
aria-labelledby
instead ofaria-label
on button.