-
Notifications
You must be signed in to change notification settings - Fork 346
[WIP] File upload refactorings #5756
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
Draft
romaricpascal
wants to merge
6
commits into
main
Choose a base branch
from
file-upload-refactoring
base: main
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.
Draft
Conversation
This file contains hidden or 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
It's only used in the component's constructor, so no need to store it
The input is hidden, so no longer needs tabindex or aria-hidden attributes
Using getter and setter rather than methods makes for less code and clearly highlights that the component is aware of a specific state: whether the user is dragging or not
Separate logic specific to the FileUpload from the logistics of listening to drag and drop events so we can move the logic for listening to drag and drop more easily.
Encapsulate the technical logistics of detecting when a user enters or leaves a specific element while dragging reliably across browsers. This allows to focus the code of the FileUpload component on the logic needed to handle the file upload, not handle browsers difference of implementation.
This clearly highlights that the component manages a `disabled` state, linked to the state of its button and synchronised with a class on the root.
📋 StatsFile sizes
Modules
View stats and visualisations on the review app Action run for d7f212d |
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 40ce6ccf5..e53c6daab 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
@@ -13,20 +13,20 @@ function getBreakpoint(t) {
}
function setFocus(t, e = {}) {
- var s;
- const n = t.getAttribute("tabindex");
+ var n;
+ const i = t.getAttribute("tabindex");
function onBlur() {
- var s;
- null == (s = e.onBlur) || s.call(t), n || t.removeAttribute("tabindex")
+ var n;
+ null == (n = e.onBlur) || n.call(t), i || t.removeAttribute("tabindex")
}
- n || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
+ i || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
t.addEventListener("blur", onBlur, {
once: !0
})
}), {
once: !0
- }), null == (s = e.onBeforeFocus) || s.call(t), t.focus()
+ }), null == (n = e.onBeforeFocus) || n.call(t), t.focus()
}
function isSupported(t = document.body) {
@@ -63,12 +63,12 @@ class ElementError extends GOVUKFrontendError {
let e = "string" == typeof t ? t : "";
if ("object" == typeof t) {
const {
- component: s,
- identifier: n,
- element: i,
+ component: n,
+ identifier: i,
+ element: s,
expectedType: o
} = t;
- e = n, e += i ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(s, e)
+ e = i, e += s ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found", e = formatErrorMessage(n, e)
}
super(e), this.name = "ElementError"
}
@@ -93,8 +93,8 @@ class Component {
expectedType: e.elementType.name
});
this._$root = t, e.checkSupport(), this.checkInitialised();
- const s = e.moduleName;
- this.$root.setAttribute(`data-${s}-init`, "")
+ const n = e.moduleName;
+ this.$root.setAttribute(`data-${n}-init`, "")
}
checkInitialised() {
const t = this.constructor,
@@ -116,91 +116,91 @@ class ConfigurableComponent extends Component {
get config() {
return this._config
}
- constructor(e, s) {
+ constructor(e, n) {
super(e), this._config = void 0;
- const n = this.constructor;
- if (!isObject(n.defaults)) throw new ConfigError(formatErrorMessage(n, "Config passed as parameter into constructor but no defaults defined"));
- const i = function(Component, t) {
+ const i = this.constructor;
+ if (!isObject(i.defaults)) throw new ConfigError(formatErrorMessage(i, "Config passed as parameter into constructor but no defaults defined"));
+ const s = function(Component, t) {
if (!isObject(Component.schema)) throw new ConfigError(formatErrorMessage(Component, "Config passed as parameter into constructor but no schema defined"));
const e = {},
- s = Object.entries(Component.schema.properties);
- for (const n of s) {
- const [s, i] = n, o = s.toString();
- o in t && (e[o] = normaliseString(t[o], i)), "object" === (null == i ? void 0 : i.type) && (e[o] = extractConfigByNamespace(Component.schema, t, s))
+ n = Object.entries(Component.schema.properties);
+ for (const i of n) {
+ const [n, s] = i, o = n.toString();
+ o in t && (e[o] = normaliseString(t[o], s)), "object" === (null == s ? void 0 : s.type) && (e[o] = extractConfigByNamespace(Component.schema, t, n))
}
return e
- }(n, this._$root.dataset);
- this._config = mergeConfigs(n.defaults, null != s ? s : {}, 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 s = t ? t.trim() : "";
- let n, i = null == e ? void 0 : e.type;
- switch (i || (["true", "false"].includes(s) && (i = "boolean"), s.length > 0 && isFinite(Number(s)) && (i = "number")), i) {
+ const n = t ? t.trim() : "";
+ let i, s = null == e ? void 0 : e.type;
+ switch (s || (["true", "false"].includes(n) && (s = "boolean"), n.length > 0 && isFinite(Number(n)) && (s = "number")), s) {
case "boolean":
- n = "true" === s;
+ i = "true" === n;
break;
case "number":
- n = Number(s);
+ i = Number(n);
break;
default:
- n = t
+ i = t
}
- return n
+ return i
}
function mergeConfigs(...t) {
const e = {};
- for (const s of t)
- for (const t of Object.keys(s)) {
- const n = e[t],
- i = s[t];
- isObject(n) && isObject(i) ? e[t] = mergeConfigs(n, i) : e[t] = i
+ for (const n of t)
+ for (const t of Object.keys(n)) {
+ const i = e[t],
+ s = n[t];
+ isObject(i) && isObject(s) ? e[t] = mergeConfigs(i, s) : e[t] = s
}
return e
}
-function extractConfigByNamespace(t, e, s) {
- const n = t.properties[s];
- if ("object" !== (null == n ? void 0 : n.type)) return;
- const i = {
- [s]: {}
+function extractConfigByNamespace(t, e, n) {
+ 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 [n, i] of e.entries()) isObject(t) && (n < e.length - 1 ? (isObject(t[i]) || (t[i] = {}), t = t[i]) : o !== s && (t[i] = normaliseString(r)))
+ for (const [i, s] of e.entries()) isObject(t) && (i < e.length - 1 ? (isObject(t[s]) || (t[s] = {}), t = t[s]) : o !== n && (t[s] = normaliseString(r)))
}
- return i[s]
+ return s[n]
}
class I18n {
constructor(t = {}, e = {}) {
- var s;
- this.translations = void 0, this.locale = void 0, this.translations = t, this.locale = null != (s = e.locale) ? s : document.documentElement.lang || "en"
+ var n;
+ this.translations = void 0, this.locale = void 0, this.translations = t, this.locale = null != (n = e.locale) ? n : document.documentElement.lang || "en"
}
t(t, e) {
if (!t) throw new Error("i18n: lookup key missing");
- let s = this.translations[t];
- if ("number" == typeof(null == e ? void 0 : e.count) && "object" == typeof s) {
- const n = s[this.getPluralSuffix(t, e.count)];
- n && (s = n)
+ let n = this.translations[t];
+ if ("number" == typeof(null == e ? void 0 : e.count) && "object" == typeof n) {
+ const i = n[this.getPluralSuffix(t, e.count)];
+ i && (n = i)
}
- if ("string" == typeof s) {
- if (s.match(/%{(.\S+)}/)) {
+ if ("string" == typeof n) {
+ if (n.match(/%{(.\S+)}/)) {
if (!e) throw new Error("i18n: cannot replace placeholders in string if no option data provided");
- return this.replacePlaceholders(s, e)
+ return this.replacePlaceholders(n, e)
}
- return s
+ return n
}
return t
}
replacePlaceholders(t, e) {
- const s = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
- return t.replace(/%{(.\S+)}/g, (function(t, n) {
- if (Object.prototype.hasOwnProperty.call(e, n)) {
- const t = e[n];
- return !1 === t || "number" != typeof t && "string" != typeof t ? "" : "number" == typeof t ? s ? s.format(t) : `${t}` : t
+ const n = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
+ return t.replace(/%{(.\S+)}/g, (function(t, i) {
+ if (Object.prototype.hasOwnProperty.call(e, i)) {
+ const t = e[i];
+ return !1 === t || "number" != typeof t && "string" != typeof t ? "" : "number" == typeof t ? n ? n.format(t) : `${t}` : t
}
throw new Error(`i18n: no data found to replace ${t} placeholder in string`)
}))
@@ -210,11 +210,11 @@ class I18n {
}
getPluralSuffix(t, e) {
if (e = Number(e), !isFinite(e)) return "other";
- const s = this.translations[t],
- n = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
- if ("object" == typeof s) {
- if (n in s) return n;
- if ("other" in s) return console.warn(`i18n: Missing plural form ".${n}" for "${this.locale}" locale. Falling back to ".other".`), "other"
+ const n = this.translations[t],
+ i = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
+ if ("object" == typeof n) {
+ if (i in n) return i;
+ if ("other" in n) return console.warn(`i18n: Missing plural form ".${i}" for "${this.locale}" locale. Falling back to ".other".`), "other"
}
throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`)
}
@@ -226,8 +226,8 @@ class I18n {
getPluralRulesForLocale() {
const t = this.locale.split("-")[0];
for (const e in I18n.pluralRulesMap) {
- const s = I18n.pluralRulesMap[e];
- if (s.includes(this.locale) || s.includes(t)) return e
+ const n = I18n.pluralRulesMap[e];
+ if (n.includes(this.locale) || n.includes(t)) return e
}
}
}
@@ -249,8 +249,8 @@ I18n.pluralRulesMap = {
irish: t => 1 === t ? "one" : 2 === t ? "two" : t >= 3 && t <= 6 ? "few" : t >= 7 && t <= 10 ? "many" : "other",
russian(t) {
const e = t % 100,
- s = e % 10;
- return 1 === s && 11 !== e ? "one" : s >= 2 && s <= 4 && !(e >= 12 && e <= 14) ? "few" : 0 === s || s >= 5 && s <= 9 || e >= 11 && e <= 14 ? "many" : "other"
+ n = e % 10;
+ return 1 === n && 11 !== e ? "one" : n >= 2 && n <= 4 && !(e >= 12 && e <= 14) ? "few" : 0 === n || n >= 5 && n <= 9 || e >= 11 && e <= 14 ? "many" : "other"
},
scottish: t => 1 === t || 11 === t ? "one" : 2 === t || 12 === t ? "two" : t >= 3 && t <= 10 || t >= 13 && t <= 19 ? "few" : "other",
spanish: t => 1 === t ? "one" : t % 1e6 == 0 && 0 !== t ? "many" : "other",
@@ -259,12 +259,12 @@ I18n.pluralRulesMap = {
class Accordion extends ConfigurableComponent {
constructor(t, e = {}) {
super(t, e), this.i18n = void 0, this.controlsClass = "govuk-accordion__controls", this.showAllClass = "govuk-accordion__show-all", this.showAllTextClass = "govuk-accordion__show-all-text", this.sectionClass = "govuk-accordion__section", this.sectionExpandedClass = "govuk-accordion__section--expanded", this.sectionButtonClass = "govuk-accordion__section-button", this.sectionHeaderClass = "govuk-accordion__section-header", this.sectionHeadingClass = "govuk-accordion__section-heading", this.sectionHeadingDividerClass = "govuk-accordion__section-heading-divider", this.sectionHeadingTextClass = "govuk-accordion__section-heading-text", this.sectionHeadingTextFocusClass = "govuk-accordion__section-heading-text-focus", this.sectionShowHideToggleClass = "govuk-accordion__section-toggle", this.sectionShowHideToggleFocusClass = "govuk-accordion__section-toggle-focus", this.sectionShowHideTextClass = "govuk-accordion__section-toggle-text", this.upChevronIconClass = "govuk-accordion-nav__chevron", this.downChevronIconClass = "govuk-accordion-nav__chevron--down", this.sectionSummaryClass = "govuk-accordion__section-summary", this.sectionSummaryFocusClass = "govuk-accordion__section-summary-focus", this.sectionContentClass = "govuk-accordion__section-content", this.$sections = void 0, this.$showAllButton = null, this.$showAllIcon = null, this.$showAllText = null, this.i18n = new I18n(this.config.i18n);
- const s = this.$root.querySelectorAll(`.${this.sectionClass}`);
- if (!s.length) throw new ElementError({
+ const n = this.$root.querySelectorAll(`.${this.sectionClass}`);
+ if (!n.length) throw new ElementError({
component: Accordion,
identifier: `Sections (\`<div class="${this.sectionClass}">\`)`
});
- this.$sections = s, this.initControls(), this.initSectionHeaders(), this.updateShowAllButton(this.areAllSectionsOpen())
+ this.$sections = n, this.initControls(), this.initSectionHeaders(), this.updateShowAllButton(this.areAllSectionsOpen())
}
initControls() {
this.$showAllButton = document.createElement("button"), this.$showAllButton.setAttribute("type", "button"), this.$showAllButton.setAttribute("class", this.showAllClass), this.$showAllButton.setAttribute("aria-expanded", "false"), this.$showAllIcon = document.createElement("span"), this.$showAllIcon.classList.add(this.upChevronIconClass), this.$showAllButton.appendChild(this.$showAllIcon);
@@ -273,53 +273,53 @@ class Accordion extends ConfigurableComponent {
}
initSectionHeaders() {
this.$sections.forEach(((t, e) => {
- const s = t.querySelector(`.${this.sectionHeaderClass}`);
- if (!s) throw new ElementError({
+ const n = t.querySelector(`.${this.sectionHeaderClass}`);
+ if (!n) throw new ElementError({
component: Accordion,
identifier: `Section headers (\`<div class="${this.sectionHeaderClass}">\`)`
});
- this.constructHeaderMarkup(s, e), this.setExpanded(this.isExpanded(t), t), s.addEventListener("click", (() => this.onSectionToggle(t))), this.setInitialState(t)
+ this.constructHeaderMarkup(n, e), this.setExpanded(this.isExpanded(t), t), n.addEventListener("click", (() => this.onSectionToggle(t))), this.setInitialState(t)
}))
}
constructHeaderMarkup(t, e) {
- const s = t.querySelector(`.${this.sectionButtonClass}`),
- n = t.querySelector(`.${this.sectionHeadingClass}`),
- i = t.querySelector(`.${this.sectionSummaryClass}`);
- if (!n) throw new ElementError({
+ const n = t.querySelector(`.${this.sectionButtonClass}`),
+ i = t.querySelector(`.${this.sectionHeadingClass}`),
+ s = t.querySelector(`.${this.sectionSummaryClass}`);
+ if (!i) throw new ElementError({
component: Accordion,
identifier: `Section heading (\`.${this.sectionHeadingClass}\`)`
});
- if (!s) throw new ElementError({
+ if (!n) throw new ElementError({
component: Accordion,
identifier: `Section button placeholder (\`<span class="${this.sectionButtonClass}">\`)`
});
const o = document.createElement("button");
o.setAttribute("type", "button"), o.setAttribute("aria-controls", `${this.$root.id}-content-${e+1}`);
- for (const d of Array.from(s.attributes)) "id" !== d.name && o.setAttribute(d.name, d.value);
+ for (const d of Array.from(n.attributes)) "id" !== d.name && o.setAttribute(d.name, d.value);
const r = document.createElement("span");
- r.classList.add(this.sectionHeadingTextClass), r.id = s.id;
+ r.classList.add(this.sectionHeadingTextClass), r.id = n.id;
const a = document.createElement("span");
- a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), Array.from(s.childNodes).forEach((t => a.appendChild(t)));
- const c = document.createElement("span");
- c.classList.add(this.sectionShowHideToggleClass), c.setAttribute("data-nosnippet", "");
+ a.classList.add(this.sectionHeadingTextFocusClass), r.appendChild(a), Array.from(n.childNodes).forEach((t => a.appendChild(t)));
const l = document.createElement("span");
- l.classList.add(this.sectionShowHideToggleFocusClass), c.appendChild(l);
- const h = document.createElement("span"),
- u = document.createElement("span");
- if (u.classList.add(this.upChevronIconClass), l.appendChild(u), h.classList.add(this.sectionShowHideTextClass), l.appendChild(h), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), i) {
+ l.classList.add(this.sectionShowHideToggleClass), l.setAttribute("data-nosnippet", "");
+ const c = document.createElement("span");
+ c.classList.add(this.sectionShowHideToggleFocusClass), l.appendChild(c);
+ const u = document.createElement("span"),
+ h = document.createElement("span");
+ if (h.classList.add(this.upChevronIconClass), c.appendChild(h), u.classList.add(this.sectionShowHideTextClass), c.appendChild(u), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), s) {
const t = document.createElement("span"),
e = document.createElement("span");
e.classList.add(this.sectionSummaryFocusClass), t.appendChild(e);
- for (const s of Array.from(i.attributes)) t.setAttribute(s.name, s.value);
- Array.from(i.childNodes).forEach((t => e.appendChild(t))), i.remove(), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
+ for (const n of Array.from(s.attributes)) t.setAttribute(n.name, n.value);
+ Array.from(s.childNodes).forEach((t => e.appendChild(t))), s.remove(), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
}
- o.appendChild(c), n.removeChild(s), n.appendChild(o)
+ o.appendChild(l), i.removeChild(n), i.appendChild(o)
}
onBeforeMatch(t) {
const e = t.target;
if (!(e instanceof Element)) return;
- const s = e.closest(`.${this.sectionClass}`);
- s && this.setExpanded(!0, s)
+ const n = e.closest(`.${this.sectionClass}`);
+ n && this.setExpanded(!0, n)
}
onSectionToggle(t) {
const e = !this.isExpanded(t);
@@ -332,24 +332,24 @@ class Accordion extends ConfigurableComponent {
})), this.updateShowAllButton(t)
}
setExpanded(t, e) {
- const s = e.querySelector(`.${this.upChevronIconClass}`),
- n = e.querySelector(`.${this.sectionShowHideTextClass}`),
- i = e.querySelector(`.${this.sectionButtonClass}`),
+ const n = e.querySelector(`.${this.upChevronIconClass}`),
+ i = e.querySelector(`.${this.sectionShowHideTextClass}`),
+ s = e.querySelector(`.${this.sectionButtonClass}`),
o = e.querySelector(`.${this.sectionContentClass}`);
if (!o) throw new ElementError({
component: Accordion,
identifier: `Section content (\`<div class="${this.sectionContentClass}">\`)`
});
- if (!s || !n || !i) return;
+ if (!n || !i || !s) return;
const r = t ? this.i18n.t("hideSection") : this.i18n.t("showSection");
- n.textContent = r, i.setAttribute("aria-expanded", `${t}`);
+ i.textContent = r, s.setAttribute("aria-expanded", `${t}`);
const a = [],
- c = e.querySelector(`.${this.sectionHeadingTextClass}`);
- c && a.push(`${c.textContent}`.trim());
- const l = e.querySelector(`.${this.sectionSummaryClass}`);
+ l = e.querySelector(`.${this.sectionHeadingTextClass}`);
l && a.push(`${l.textContent}`.trim());
- const h = t ? this.i18n.t("hideSectionAriaLabel") : this.i18n.t("showSectionAriaLabel");
- a.push(h), i.setAttribute("aria-label", a.join(" , ")), t ? (o.removeAttribute("hidden"), e.classList.add(this.sectionExpandedClass), s.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), e.classList.remove(this.sectionExpandedClass), s.classList.add(this.downChevronIconClass)), this.updateShowAllButton(this.areAllSectionsOpen())
+ const c = e.querySelector(`.${this.sectionSummaryClass}`);
+ c && a.push(`${c.textContent}`.trim());
+ const u = t ? this.i18n.t("hideSectionAriaLabel") : this.i18n.t("showSectionAriaLabel");
+ a.push(u), 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)
@@ -366,18 +366,18 @@ class Accordion extends ConfigurableComponent {
}
storeState(t, e) {
if (!this.config.rememberExpanded) return;
- const s = this.getIdentifier(t);
- if (s) try {
- window.sessionStorage.setItem(s, e.toString())
- } catch (n) {}
+ const n = this.getIdentifier(t);
+ if (n) try {
+ window.sessionStorage.setItem(n, e.toString())
+ } catch (i) {}
}
setInitialState(t) {
if (!this.config.rememberExpanded) return;
const e = this.getIdentifier(t);
if (e) try {
- const s = window.sessionStorage.getItem(e);
- null !== s && this.setExpanded("true" === s, t)
- } catch (s) {}
+ const n = window.sessionStorage.getItem(e);
+ null !== n && this.setExpanded("true" === n, t)
+ } catch (n) {}
}
getButtonPunctuationEl() {
const t = document.createElement("span");
@@ -420,8 +420,8 @@ class Button extends ConfigurableComponent {
}
function closestAttributeValue(t, e) {
- const s = t.closest(`[${e}]`);
- return s ? s.getAttribute(e) : null
+ const n = t.closest(`[${e}]`);
+ return n ? n.getAttribute(e) : null
}
Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
preventDoubleClick: !1
@@ -441,34 +441,34 @@ class CharacterCount extends ConfigurableComponent {
}), e
}
constructor(t, e = {}) {
- var s, n;
+ 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 s = [];
- for (const [n, i] of Object.entries(t)) {
+ const n = [];
+ for (const [i, s] of Object.entries(t)) {
const t = [];
- if (Array.isArray(i)) {
+ if (Array.isArray(s)) {
for (const {
- required: s,
- errorMessage: n
+ required: n,
+ errorMessage: i
}
- of i) s.every((t => !!e[t])) || t.push(n);
- "anyOf" !== n || i.length - t.length >= 1 || s.push(...t)
+ of s) n.every((t => !!e[t])) || t.push(i);
+ "anyOf" !== i || s.length - t.length >= 1 || n.push(...t)
}
}
- return s
+ return n
}(CharacterCount.schema, this.config);
if (o[0]) throw new ConfigError(formatErrorMessage(CharacterCount, o[0]));
this.i18n = new I18n(this.config.i18n, {
locale: closestAttributeValue(this.$root, "lang")
- }), this.maxLength = null != (s = null != (n = this.config.maxwords) ? n : this.config.maxlength) ? s : 1 / 0, this.$textarea = i;
+ }), 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({
@@ -479,10 +479,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()))
@@ -525,8 +525,8 @@ class CharacterCount extends ConfigurableComponent {
}
formatCountMessage(t, e) {
if (0 === t) return this.i18n.t(`${e}AtLimit`);
- const s = t < 0 ? "OverLimit" : "UnderLimit";
- return this.i18n.t(`${e}${s}`, {
+ const n = t < 0 ? "OverLimit" : "UnderLimit";
+ return this.i18n.t(`${e}${n}`, {
count: Math.abs(t)
})
}
@@ -609,10 +609,10 @@ class Checkboxes extends Component {
syncConditionalRevealWithInputState(t) {
const e = t.getAttribute("aria-controls");
if (!e) return;
- const s = document.getElementById(e);
- if (null != s && s.classList.contains("govuk-checkboxes__conditional")) {
+ const n = document.getElementById(e);
+ if (null != n && n.classList.contains("govuk-checkboxes__conditional")) {
const e = t.checked;
- t.setAttribute("aria-expanded", e.toString()), s.classList.toggle("govuk-checkboxes__conditional--hidden", !e)
+ t.setAttribute("aria-expanded", e.toString()), n.classList.toggle("govuk-checkboxes__conditional--hidden", !e)
}
}
unCheckAllInputsExcept(t) {
@@ -645,25 +645,25 @@ class ErrorSummary extends ConfigurableComponent {
if (!(t instanceof HTMLAnchorElement)) return !1;
const e = getFragmentFromUrl(t.href);
if (!e) return !1;
- const s = document.getElementById(e);
- if (!s) return !1;
- const n = this.getAssociatedLegendOrLabel(s);
- return !!n && (n.scrollIntoView(), s.focus({
+ const n = document.getElementById(e);
+ if (!n) return !1;
+ const i = this.getAssociatedLegendOrLabel(n);
+ return !!i && (i.scrollIntoView(), n.focus({
preventScroll: !0
}), !0)
}
getAssociatedLegendOrLabel(t) {
var e;
- const s = t.closest("fieldset");
- if (s) {
- const e = s.getElementsByTagName("legend");
+ const n = t.closest("fieldset");
+ if (n) {
+ const e = n.getElementsByTagName("legend");
if (e.length) {
- const s = e[0];
- if (t instanceof HTMLInputElement && ("checkbox" === t.type || "radio" === t.type)) return s;
- const n = s.getBoundingClientRect().top,
- i = t.getBoundingClientRect();
- if (i.height && window.innerHeight) {
- if (i.top + i.height - n < window.innerHeight / 2) return s
+ const n = e[0];
+ if (t instanceof HTMLInputElement && ("checkbox" === t.type || "radio" === t.type)) return n;
+ const i = n.getBoundingClientRect().top,
+ s = t.getBoundingClientRect();
+ if (s.height && window.innerHeight) {
+ if (s.top + s.height - i < window.innerHeight / 2) return n
}
}
}
@@ -682,16 +682,16 @@ ErrorSummary.moduleName = "govuk-error-summary", ErrorSummary.defaults = Object.
class ExitThisPage extends ConfigurableComponent {
constructor(t, e = {}) {
super(t, e), this.i18n = void 0, this.$button = void 0, this.$skiplinkButton = null, this.$updateSpan = null, this.$indicatorContainer = null, this.$overlay = null, this.keypressCounter = 0, this.lastKeyWasModified = !1, this.timeoutTime = 5e3, this.keypressTimeoutId = null, this.timeoutMessageId = null;
- const s = this.$root.querySelector(".govuk-exit-this-page__button");
- if (!(s instanceof HTMLAnchorElement)) throw new ElementError({
+ const n = this.$root.querySelector(".govuk-exit-this-page__button");
+ if (!(n instanceof HTMLAnchorElement)) throw new ElementError({
component: ExitThisPage,
- element: s,
+ element: n,
expectedType: "HTMLAnchorElement",
identifier: "Button (`.govuk-exit-this-page__button`)"
});
- this.i18n = new I18n(this.config.i18n), this.$button = s;
- const n = document.querySelector(".govuk-js-exit-this-page-skiplink");
- n instanceof HTMLAnchorElement && (this.$skiplinkButton = n), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
+ this.i18n = new I18n(this.config.i18n), this.$button = n;
+ const i = document.querySelector(".govuk-js-exit-this-page-skiplink");
+ i instanceof HTMLAnchorElement && (this.$skiplinkButton = i), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
}
initUpdateSpan() {
this.$updateSpan = document.createElement("span"), this.$updateSpan.setAttribute("role", "status"), this.$updateSpan.className = "govuk-visually-hidden", this.$root.appendChild(this.$updateSpan)
@@ -752,23 +752,150 @@ ExitThisPage.moduleName = "govuk-exit-this-page", ExitThisPage.defaults = Object
}
}
});
+class FileUpload extends ConfigurableComponent {
+ constructor(t, e = {}) {
+ super(t, e), this.$input = void 0, this.$button = void 0, this.$status = void 0, this.i18n = void 0;
+ const n = this.$root.querySelector("input");
+ if (null === n) throw new ElementError({
+ component: FileUpload,
+ identifier: 'File inputs (`<input type="file">`)'
+ });
+ if ("file" !== n.type) throw new ElementError(formatErrorMessage(FileUpload, 'File input (`<input type="file">`) attribute (`type`) is not `file`'));
+ this.$input = n, this.$input.setAttribute("hidden", "true");
+ const i = this.$input.id;
+ if (!i) throw new ElementError({
+ component: FileUpload,
+ identifier: 'File input (`<input type="file">`) attribute (`id`)'
+ });
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.$root, "lang")
+ });
+ const s = this.findLabel(i);
+ s.id || (s.id = `${i}-label`), this.$input.id = `${i}-input`;
+ const o = document.createElement("button");
+ o.classList.add("govuk-file-upload-button"), o.type = "button", o.id = i, o.classList.add("govuk-file-upload-button--empty");
+ const r = this.$input.getAttribute("aria-describedby");
+ r && o.setAttribute("aria-describedby", r);
+ const a = document.createElement("span");
+ a.className = "govuk-body govuk-file-upload-button__status", a.setAttribute("aria-live", "polite"), a.innerText = this.i18n.t("noFileChosen"), o.appendChild(a);
+ const l = document.createElement("span");
+ l.className = "govuk-visually-hidden", l.innerText = ", ", l.id = `${i}-comma`, o.appendChild(l);
+ const c = document.createElement("span");
+ c.className = "govuk-file-upload-button__pseudo-button-container";
+ const u = document.createElement("span");
+ u.className = "govuk-button govuk-button--secondary govuk-file-upload-button__pseudo-button", u.innerText = this.i18n.t("chooseFilesButton"), c.appendChild(u), c.insertAdjacentText("beforeend", " ");
+ const h = document.createElement("span");
+ h.className = "govuk-body govuk-file-upload-button__instruction", h.innerText = this.i18n.t("dropInstruction"), c.appendChild(h), o.appendChild(c), o.setAttribute("aria-labelledby", `${s.id} ${l.id} ${o.id}`), o.addEventListener("click", this.onClick.bind(this)), o.addEventListener("dragover", (t => {
+ t.preventDefault()
+ })), this.$root.insertAdjacentElement("afterbegin", o), this.$button = o, this.$status = a, this.$input.addEventListener("change", this.onChange.bind(this)), this.synchroniseDisabledState(), 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.$root.insertAdjacentElement("afterend", this.$announcements), this.$button.addEventListener("drop", this.onDrop.bind(this)), this.dropZone = new DropZone(this.$root, {
+ onEnter: this.onDropZoneEnter.bind(this),
+ onLeave: this.onDropZoneLeave.bind(this)
+ })
+ }
+ get disabled() {
+ return this.$button.disabled
+ }
+ set disabled(t) {
+ this.$button.disabled = t, this.$root.classList.toggle("govuk-drop-zone--disabled", t)
+ }
+ get dragging() {
+ return this.$button.classList.contains("govuk-file-upload-button--dragging")
+ }
+ set dragging(t) {
+ this.$button.classList.toggle("govuk-file-upload-button--dragging", t)
+ }
+ onDropZoneEnter(t) {
+ this.disabled || t.dataTransfer && isContainingFiles(t.dataTransfer) && (this.dragging || (this.dragging = !0, this.$announcements.innerText = this.i18n.t("enteredDropZone")))
+ }
+ onDropZoneLeave() {
+ this.disabled || this.dragging && (this.dragging = !1, this.$announcements.innerText = this.i18n.t("leftDropZone"))
+ }
+ onDrop(t) {
+ t.preventDefault(), t.dataTransfer && isContainingFiles(t.dataTransfer) && (this.$input.files = t.dataTransfer.files, this.$input.dispatchEvent(new CustomEvent("change")), this.dragging = !1)
+ }
+ onChange() {
+ const t = this.$input.files.length;
+ 0 === t ? (this.$status.innerText = this.i18n.t("noFileChosen"), this.$button.classList.add("govuk-file-upload-button--empty")) : (this.$status.innerText = 1 === t ? this.$input.files[0].name : this.i18n.t("multipleFilesChosen", {
+ count: t
+ }), this.$button.classList.remove("govuk-file-upload-button--empty"))
+ }
+ findLabel(t) {
+ const e = document.querySelector(`label[for="${t}"]`);
+ if (!e) throw new ElementError({
+ component: FileUpload,
+ identifier: `Field label (\`<label for=${t}>\`)`
+ });
+ return e
+ }
+ onClick() {
+ this.$input.click()
+ }
+ synchroniseDisabledState() {
+ this.disabled = this.$input.disabled;
+ new MutationObserver((t => {
+ for (const e of t) "attributes" === e.type && "disabled" === e.attributeName && (this.disabled = this.$input.disabled)
+ })).observe(this.$input, {
+ attributes: !0
+ })
+ }
+}
+FileUpload.moduleName = "govuk-file-upload", FileUpload.defaults = Object.freeze({
+ i18n: {
+ chooseFilesButton: "Choose file",
+ dropInstruction: "or drop file",
+ noFileChosen: "No file chosen",
+ multipleFilesChosen: {
+ one: "%{count} file chosen",
+ other: "%{count} files chosen"
+ },
+ enteredDropZone: "Entered drop zone",
+ leftDropZone: "Left drop zone"
+ }
+}), FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: "object"
+ }
+ }
+});
+class DropZone {
+ constructor(t, {
+ onEnter: e,
+ onLeave: n
+ }) {
+ this.$root = void 0, this.onEnter = void 0, this.onLeave = void 0, this.$root = t, this.onEnter = e, this.onLeave = n, document.addEventListener("dragenter", this.updateDropzoneVisibility.bind(this)), document.addEventListener("dragenter", (() => {
+ this.enteredAnotherElement = !0
+ })), document.addEventListener("dragleave", (t => {
+ this.enteredAnotherElement || this.onLeave(t), this.enteredAnotherElement = !1
+ }))
+ }
+ updateDropzoneVisibility(t) {
+ t.target instanceof Node && (this.$root.contains(t.target) ? this.onEnter(t) : this.onLeave(t))
+ }
+}
+
+function isContainingFiles(t) {
+ const e = 0 === t.types.length,
+ n = t.types.some((t => "Files" === t));
+ return e || n
+}
class Header extends Component {
constructor(t) {
super(t), this.$menuButton = void 0, this.$menu = void 0, this.menuIsOpen = !1, this.mql = null;
const e = this.$root.querySelector(".govuk-js-header-toggle");
if (!e) return this;
- const s = e.getAttribute("aria-controls");
- if (!s) throw new ElementError({
+ const n = e.getAttribute("aria-controls");
+ if (!n) throw new ElementError({
component: Header,
identifier: 'Navigation button (`<button class="govuk-js-header-toggle">`) attribute (`aria-controls`)'
});
- const n = document.getElementById(s);
- if (!n) throw new ElementError({
+ const i = document.getElementById(n);
+ if (!i) throw new ElementError({
component: Header,
- element: n,
- identifier: `Navigation (\`<ul id="${s}">\`)`
+ element: i,
+ identifier: `Navigation (\`<ul id="${n}">\`)`
});
- this.$menu = n, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+ this.$menu = i, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
}
setupResponsiveChecks() {
const t = getBreakpoint("desktop");
@@ -803,27 +930,27 @@ NotificationBanner.moduleName = "govuk-notification-banner", NotificationBanner.
class PasswordInput extends ConfigurableComponent {
constructor(t, e = {}) {
super(t, e), this.i18n = void 0, this.$input = void 0, this.$showHideButton = void 0, this.$screenReaderStatusMessage = void 0;
- const s = this.$root.querySelector(".govuk-js-password-input-input");
- if (!(s instanceof HTMLInputElement)) throw new ElementError({
+ const n = this.$root.querySelector(".govuk-js-password-input-input");
+ if (!(n instanceof HTMLInputElement)) throw new ElementError({
component: PasswordInput,
- element: s,
+ element: n,
expectedType: "HTMLInputElement",
identifier: "Form field (`.govuk-js-password-input-input`)"
});
- if ("password" !== s.type) throw new ElementError("Password input: Form field (`.govuk-js-password-input-input`) must be of type `password`.");
- const n = this.$root.querySelector(".govuk-js-password-input-toggle");
- if (!(n instanceof HTMLButtonElement)) throw new ElementError({
+ if ("password" !== n.type) throw new ElementError("Password input: Form field (`.govuk-js-password-input-input`) must be of type `password`.");
+ const i = this.$root.querySelector(".govuk-js-password-input-toggle");
+ if (!(i instanceof HTMLButtonElement)) throw new ElementError({
component: PasswordInput,
- element: n,
+ element: i,
expectedType: "HTMLButtonElement",
identifier: "Button (`.govuk-js-password-input-toggle`)"
});
- if ("button" !== n.type) throw new ElementError("Password input: Button (`.govuk-js-password-input-toggle`) must be of type `button`.");
- this.$input = s, this.$showHideButton = n, this.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()
}
@@ -840,9 +967,9 @@ class PasswordInput extends ConfigurableComponent {
if (t === this.$input.type) return;
this.$input.setAttribute("type", t);
const e = "password" === t,
- s = e ? "show" : "hide",
- n = e ? "passwordHidden" : "passwordShown";
- this.$showHideButton.innerText = this.i18n.t(`${s}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${s}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${n}Announcement`)
+ n = e ? "show" : "hide",
+ i = e ? "passwordHidden" : "passwordShown";
+ this.$showHideButton.innerText = this.i18n.t(`${n}Password`), this.$showHideButton.setAttribute("aria-label", this.i18n.t(`${n}PasswordAriaLabel`)), this.$screenReaderStatusMessage.innerText = this.i18n.t(`${i}Announcement`)
}
}
PasswordInput.moduleName = "govuk-password-input", PasswordInput.defaults = Object.freeze({
@@ -886,21 +1013,21 @@ class Radios extends Component {
syncConditionalRevealWithInputState(t) {
const e = t.getAttribute("aria-controls");
if (!e) return;
- const s = document.getElementById(e);
- if (null != s && s.classList.contains("govuk-radios__conditional")) {
+ const n = document.getElementById(e);
+ if (null != n && n.classList.contains("govuk-radios__conditional")) {
const e = t.checked;
- t.setAttribute("aria-expanded", e.toString()), s.classList.toggle("govuk-radios__conditional--hidden", !e)
+ t.setAttribute("aria-expanded", e.toString()), n.classList.toggle("govuk-radios__conditional--hidden", !e)
}
}
handleClick(t) {
const e = t.target;
if (!(e instanceof HTMLInputElement) || "radio" !== e.type) return;
- const s = document.querySelectorAll('input[type="radio"][aria-controls]'),
- n = e.form,
- i = e.name;
- s.forEach((t => {
- const e = t.form === n;
- t.name === i && e && this.syncConditionalRevealWithInputState(t)
+ const n = document.querySelectorAll('input[type="radio"][aria-controls]'),
+ i = e.form,
+ s = e.name;
+ n.forEach((t => {
+ const e = t.form === i;
+ t.name === s && e && this.syncConditionalRevealWithInputState(t)
}))
}
}
@@ -910,18 +1037,18 @@ class ServiceNavigation extends Component {
super(t), this.$menuButton = void 0, this.$menu = void 0, this.menuIsOpen = !1, this.mql = null;
const e = this.$root.querySelector(".govuk-js-service-navigation-toggle");
if (!e) return this;
- const s = e.getAttribute("aria-controls");
- if (!s) throw new ElementError({
+ const n = e.getAttribute("aria-controls");
+ if (!n) throw new ElementError({
component: ServiceNavigation,
identifier: 'Navigation button (`<button class="govuk-js-service-navigation-toggle">`) attribute (`aria-controls`)'
});
- const n = document.getElementById(s);
- if (!n) throw new ElementError({
+ const i = document.getElementById(n);
+ if (!i) throw new ElementError({
component: ServiceNavigation,
- element: n,
- identifier: `Navigation (\`<ul id="${s}">\`)`
+ element: i,
+ identifier: `Navigation (\`<ul id="${n}">\`)`
});
- this.$menu = n, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+ this.$menu = i, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
}
setupResponsiveChecks() {
const t = getBreakpoint("tablet");
@@ -943,17 +1070,17 @@ class SkipLink extends Component {
constructor(t) {
var e;
super(t);
- const s = this.$root.hash,
- n = null != (e = this.$root.getAttribute("href")) ? e : "";
- let i;
+ const n = this.$root.hash,
+ i = null != (e = this.$root.getAttribute("href")) ? e : "";
+ let s;
try {
- i = new window.URL(this.$root.href)
+ s = new window.URL(this.$root.href)
} catch (a) {
- throw new ElementError(`Skip link: Target link (\`href="${n}"\`) is invalid`)
+ throw new ElementError(`Skip link: Target link (\`href="${i}"\`) is invalid`)
}
- if (i.origin !== window.location.origin || i.pathname !== window.location.pathname) return;
- const o = getFragmentFromUrl(s);
- if (!o) throw new ElementError(`Skip link: Target link (\`href="${n}"\`) has no hash fragment`);
+ if (s.origin !== window.location.origin || s.pathname !== window.location.pathname) return;
+ const o = getFragmentFromUrl(n);
+ if (!o) throw new ElementError(`Skip link: Target link (\`href="${i}"\`) has no hash fragment`);
const r = document.getElementById(o);
if (!r) throw new ElementError({
component: SkipLink,
@@ -980,17 +1107,17 @@ class Tabs extends Component {
identifier: 'Links (`<a class="govuk-tabs__tab">`)'
});
this.$tabs = e, this.boundTabClick = this.onTabClick.bind(this), this.boundTabKeydown = this.onTabKeydown.bind(this), this.boundOnHashChange = this.onHashChange.bind(this);
- const s = this.$root.querySelector(".govuk-tabs__list"),
- n = this.$root.querySelectorAll("li.govuk-tabs__list-item");
- if (!s) throw new ElementError({
+ const n = this.$root.querySelector(".govuk-tabs__list"),
+ i = this.$root.querySelectorAll("li.govuk-tabs__list-item");
+ if (!n) throw new ElementError({
component: Tabs,
identifier: 'List (`<ul class="govuk-tabs__list">`)'
});
- if (!n.length) throw new ElementError({
+ if (!i.length) throw new ElementError({
component: Tabs,
identifier: 'List items (`<li class="govuk-tabs__list-item">`)'
});
- this.$tabList = s, this.$tabListItems = n, this.setupResponsiveChecks()
+ this.$tabList = n, this.$tabListItems = i, this.setupResponsiveChecks()
}
setupResponsiveChecks() {
const t = getBreakpoint("tablet");
@@ -1026,8 +1153,8 @@ class Tabs extends Component {
e = this.getTab(t);
if (!e) return;
if (this.changingHash) return void(this.changingHash = !1);
- const s = this.getCurrentTab();
- s && (this.hideTab(s), this.showTab(e), e.focus())
+ const n = this.getCurrentTab();
+ n && (this.hideTab(n), this.showTab(e), e.focus())
}
hideTab(t) {
this.unhighlightTab(t), this.hidePanel(t)
@@ -1042,8 +1169,8 @@ class Tabs extends Component {
const e = getFragmentFromUrl(t.href);
if (!e) return;
t.setAttribute("id", `tab_${e}`), t.setAttribute("role", "tab"), t.setAttribute("aria-controls", e), t.setAttribute("aria-selected", "false"), t.setAttribute("tabindex", "-1");
- const s = this.getPanel(t);
- s && (s.setAttribute("role", "tabpanel"), s.setAttribute("aria-labelledby", t.id), s.classList.add(this.jsHiddenClass))
+ const n = this.getPanel(t);
+ n && (n.setAttribute("role", "tabpanel"), n.setAttribute("aria-labelledby", t.id), n.classList.add(this.jsHiddenClass))
}
unsetAttributes(t) {
t.removeAttribute("id"), t.removeAttribute("role"), t.removeAttribute("aria-controls"), t.removeAttribute("aria-selected"), t.removeAttribute("tabindex");
@@ -1052,14 +1179,14 @@ class Tabs extends Component {
}
onTabClick(t) {
const e = this.getCurrentTab(),
- s = t.currentTarget;
- e && s instanceof HTMLAnchorElement && (t.preventDefault(), this.hideTab(e), this.showTab(s), this.createHistoryEntry(s))
+ n = t.currentTarget;
+ e && n instanceof HTMLAnchorElement && (t.preventDefault(), this.hideTab(e), this.showTab(n), this.createHistoryEntry(n))
}
createHistoryEntry(t) {
const e = this.getPanel(t);
if (!e) return;
- const s = e.id;
- e.id = "", this.changingHash = !0, window.location.hash = s, e.id = s
+ const n = e.id;
+ e.id = "", this.changingHash = !0, window.location.hash = n, e.id = n
}
onTabKeydown(t) {
switch (t.key) {
@@ -1077,16 +1204,16 @@ class Tabs extends Component {
if (null == t || !t.parentElement) return;
const e = t.parentElement.nextElementSibling;
if (!e) return;
- const s = e.querySelector("a.govuk-tabs__tab");
- s && (this.hideTab(t), this.showTab(s), s.focus(), this.createHistoryEntry(s))
+ const n = e.querySelector("a.govuk-tabs__tab");
+ n && (this.hideTab(t), this.showTab(n), n.focus(), this.createHistoryEntry(n))
}
activatePreviousTab() {
const t = this.getCurrentTab();
if (null == t || !t.parentElement) return;
const e = t.parentElement.previousElementSibling;
if (!e) return;
- const s = e.querySelector("a.govuk-tabs__tab");
- s && (this.hideTab(t), this.showTab(s), s.focus(), this.createHistoryEntry(s))
+ const n = e.querySelector("a.govuk-tabs__tab");
+ n && (this.hideTab(t), this.showTab(n), n.focus(), this.createHistoryEntry(n))
}
getPanel(t) {
const e = getFragmentFromUrl(t.href);
@@ -1116,13 +1243,14 @@ function initAll(t) {
if (t = void 0 !== t ? t : {}, !isSupported()) return void(t.onError ? t.onError(new SupportError, {
config: t
}) : console.log(new SupportError));
- const s = [
+ const n = [
[Accordion, t.accordion],
[Button, t.button],
[CharacterCount, t.characterCount],
[Checkboxes],
[ErrorSummary, t.errorSummary],
[ExitThisPage, t.exitThisPage],
+ [FileUpload, t.fileUpload],
[Header],
[NotificationBanner, t.notificationBanner],
[PasswordInput, t.passwordInput],
@@ -1131,32 +1259,32 @@ function initAll(t) {
[SkipLink],
[Tabs]
],
- n = {
+ i = {
scope: null != (e = t.scope) ? e : document,
onError: t.onError
};
- s.forEach((([Component, t]) => {
- createAll(Component, t, n)
+ n.forEach((([Component, t]) => {
+ createAll(Component, t, i)
}))
}
function createAll(Component, t, e) {
- let s, n = document;
- var i;
- "object" == typeof e && (n = null != (i = e.scope) ? i : n, s = e.onError);
- "function" == typeof e && (s = e), e instanceof HTMLElement && (n = e);
- const o = n.querySelectorAll(`[data-module="${Component.moduleName}"]`);
+ let n, i = document;
+ var s;
+ "object" == typeof e && (i = null != (s = e.scope) ? s : i, n = e.onError);
+ "function" == typeof e && (n = e), e instanceof HTMLElement && (i = e);
+ const o = i.querySelectorAll(`[data-module="${Component.moduleName}"]`);
return isSupported() ? Array.from(o).map((e => {
try {
return void 0 !== t ? new Component(e, t) : new Component(e)
- } catch (n) {
- return s ? s(n, {
+ } catch (i) {
+ return n ? n(i, {
element: e,
component: Component,
config: t
- }) : console.log(n), null
+ }) : console.log(i), null
}
- })).filter(Boolean) : (s ? s(new SupportError, {
+ })).filter(Boolean) : (n ? n(new SupportError, {
component: Component,
config: t
}) : console.log(new SupportError), [])
@@ -1171,6 +1299,7 @@ export {
ConfigurableComponent,
ErrorSummary,
ExitThisPage,
+ FileUpload,
Header,
NotificationBanner,
PasswordInput,
Action run for d7f212d |
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 a95912b7a..0a90d0d3e 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.css
@@ -3389,6 +3389,150 @@ screen and (forced-colors:active) {
cursor: not-allowed
}
+.govuk-drop-zone {
+ display: block;
+ position: relative;
+ z-index: 0;
+ background-color: #fff
+}
+
+.govuk-drop-zone--disabled {
+ cursor: not-allowed
+}
+
+.govuk-file-upload-button__pseudo-button {
+ width: auto;
+ margin-right: 10px;
+ margin-bottom: 3px;
+ flex-shrink: 0
+}
+
+.govuk-file-upload-button__instruction {
+ margin-top: 7px;
+ margin-bottom: 0;
+ text-align: left
+}
+
+.govuk-file-upload-button__status {
+ display: block;
+ margin-bottom: 10px;
+ padding: 15px 10px;
+ background-color: #fff;
+ text-align: left
+}
+
+.govuk-file-upload-button__pseudo-button-container {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap
+}
+
+.govuk-file-upload-button {
+ width: 100%;
+ padding: 18px;
+ border: 2px solid #b1b4b6;
+ background-color: #f3f2f1;
+ cursor: pointer
+}
+
+@media (min-width:40.0625em) {
+ .govuk-file-upload-button {
+ padding: 23px
+ }
+}
+
+.govuk-file-upload-button .govuk-file-upload-button__pseudo-button {
+ background-color: #fff
+}
+
+.govuk-file-upload-button:hover {
+ background-color: #c1c3c5
+}
+
+.govuk-file-upload-button:hover .govuk-file-upload-button__pseudo-button {
+ background-color: #dbdad9
+}
+
+.govuk-file-upload-button:hover .govuk-file-upload-button__status {
+ background-color: #d2e2f1
+}
+
+.govuk-file-upload-button:active,
+.govuk-file-upload-button:focus {
+ border: 2px solid #0b0c0c;
+ outline: 3px solid #fd0;
+ outline-offset: 0;
+ background-color: #c1c3c5;
+ box-shadow: inset 0 0 0 2px
+}
+
+.govuk-file-upload-button:active .govuk-file-upload-button__pseudo-button,
+.govuk-file-upload-button:focus .govuk-file-upload-button__pseudo-button {
+ background-color: #fd0;
+ box-shadow: 0 2px 0 #0b0c0c
+}
+
+.govuk-file-upload-button:active:hover .govuk-file-upload-button__pseudo-button,
+.govuk-file-upload-button:focus:hover .govuk-file-upload-button__pseudo-button {
+ border-color: #fd0;
+ outline: 3px solid transparent;
+ background-color: #f3f2f1;
+ box-shadow: inset 0 0 0 1px #fd0
+}
+
+.govuk-file-upload-button:disabled {
+ pointer-events: none;
+ opacity: .5
+}
+
+.govuk-file-upload-button--empty {
+ border-style: dashed;
+ background-color: #fff
+}
+
+.govuk-file-upload-button--empty .govuk-file-upload-button__pseudo-button {
+ background-color: #f3f2f1
+}
+
+.govuk-file-upload-button--empty .govuk-file-upload-button__status {
+ color: #0c2d4a;
+ background-color: #bbd4ea
+}
+
+.govuk-file-upload-button--empty:active,
+.govuk-file-upload-button--empty:focus,
+.govuk-file-upload-button--empty:hover {
+ background-color: #f3f2f1
+}
+
+.govuk-file-upload-button--empty:active .govuk-file-upload-button__status,
+.govuk-file-upload-button--empty:focus .govuk-file-upload-button__status,
+.govuk-file-upload-button--empty:hover .govuk-file-upload-button__status {
+ background-color: #d2e2f1
+}
+
+.govuk-file-upload-button--dragging {
+ border-style: solid;
+ border-color: #0b0c0c
+}
+
+.govuk-file-upload-button--dragging.govuk-file-upload-button {
+ background-color: #c1c3c5
+}
+
+.govuk-file-upload-button--dragging.govuk-file-upload-button--empty {
+ background-color: #f3f2f1
+}
+
+.govuk-file-upload-button--dragging.govuk-file-upload-button--empty .govuk-file-upload-button__pseudo-button,
+.govuk-file-upload-button--dragging.govuk-file-upload-button--empty:not(:disabled) .govuk-file-upload-button__status {
+ background-color: #fff
+}
+
+.govuk-file-upload-button--dragging .govuk-file-upload-button__pseudo-button {
+ background-color: #dbdad9
+}
+
.govuk-footer {
font-family: GDS Transport, arial, sans-serif;
-webkit-font-smoothing: antialiased;
Action run for d7f212d |
Rendered HTML changes to npm packagediff --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-direct-media-capture.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-direct-media-capture.html
index 68d350f46..3aced0092 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-direct-media-capture.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" 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..a68672605
--- /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" accept="image/*">
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-multiple-files.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-multiple-files.html
new file mode 100644
index 000000000..8b66854ec
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-allows-multiple-files.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" multiple>
+</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..ba9699b64
--- /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" disabled>
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced-disabled.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced-disabled.html
new file mode 100644
index 000000000..42f3cbcaf
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced-disabled.html
@@ -0,0 +1,13 @@
+<div class="govuk-form-group">
+ <label class="govuk-label" for="file-upload-error">
+ Upload a file
+ </label>
+ <div id="file-upload-error-hint" class="govuk-hint">
+ Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.
+ </div>
+ <div
+ class="govuk-drop-zone"
+ data-module="govuk-file-upload">
+ <input class="govuk-file-upload" id="file-upload-error" name="file-upload-error" type="file" disabled aria-describedby="file-upload-error-hint">
+ </div>
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced-with-error-message-and-hint.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced-with-error-message-and-hint.html
new file mode 100644
index 000000000..b6abfe22e
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced-with-error-message-and-hint.html
@@ -0,0 +1,16 @@
+<div class="govuk-form-group govuk-form-group--error">
+ <label class="govuk-label" for="file-upload-3">
+ Upload a file
+ </label>
+ <div id="file-upload-3-hint" class="govuk-hint">
+ Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto.
+ </div>
+ <p id="file-upload-3-error" class="govuk-error-message">
+ <span class="govuk-visually-hidden">Error:</span> Error message goes here
+ </p>
+ <div
+ class="govuk-drop-zone"
+ data-module="govuk-file-upload">
+ <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">
+ </div>
+</div>
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced.html b/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced.html
new file mode 100644
index 000000000..941aae0b3
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-enhanced.html
@@ -0,0 +1,10 @@
+<div class="govuk-form-group">
+ <label class="govuk-label" for="file-upload-1">
+ Upload a file
+ </label>
+ <div
+ class="govuk-drop-zone"
+ data-module="govuk-file-upload">
+ <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file">
+ </div>
+</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..00a8076b1
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/template-translated.html
@@ -0,0 +1,10 @@
+<div class="govuk-form-group">
+ <label class="govuk-label" for="file-upload-1">
+ Llwythwch ffeil i fyny
+ </label>
+ <div
+ class="govuk-drop-zone"
+ data-module="govuk-file-upload" data-i18n.choose-files-button="Dewiswch ffeil" data-i18n.no-file-chosen="Dim ffeil wedi'i dewis" data-i18n.multiple-files-chosen.other="%{count} ffeil wedi'u dewis" data-i18n.multiple-files-chosen.one="%{count} ffeil wedi'i dewis" data-i18n.drop-instruction="neu ollwng ffeil" data-i18n.entered-drop-zone="Wedi mynd i mewn i'r parth gollwng" data-i18n.left-drop-zone="Parth gollwng i'r chwith">
+ <input class="govuk-file-upload" id="file-upload-1" name="file-upload-1" type="file" multiple>
+ </div>
+</div>
Action run for d7f212d |
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 a04a0facd..02061e80c 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -1662,6 +1662,316 @@
}
});
+ /**
+ * File upload component
+ *
+ * @preserve
+ * @augments ConfigurableComponent<FileUploadConfig>
+ */
+ class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$input = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.i18n = void 0;
+ const $input = this.$root.querySelector('input');
+ if ($input === null) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File inputs (`<input type="file">`)'
+ });
+ }
+ if ($input.type !== 'file') {
+ throw new ElementError(formatErrorMessage(FileUpload, 'File input (`<input type="file">`) attribute (`type`) is not `file`'));
+ }
+ this.$input = $input;
+ this.$input.setAttribute('hidden', 'true');
+ const fieldId = this.$input.id;
+ if (!fieldId) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File input (`<input type="file">`) attribute (`id`)'
+ });
+ }
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.$root, 'lang')
+ });
+ const $label = this.findLabel(fieldId);
+ if (!$label.id) {
+ $label.id = `${fieldId}-label`;
+ }
+ this.$input.id = `${fieldId}-input`;
+ const $button = document.createElement('button');
+ $button.classList.add('govuk-file-upload-button');
+ $button.type = 'button';
+ $button.id = fieldId;
+ $button.classList.add('govuk-file-upload-button--empty');
+ const ariaDescribedBy = this.$input.getAttribute('aria-describedby');
+ if (ariaDescribedBy) {
+ $button.setAttribute('aria-describedby', ariaDescribedBy);
+ }
+ const $status = document.createElement('span');
+ $status.className = 'govuk-body govuk-file-upload-button__status';
+ $status.setAttribute('aria-live', 'polite');
+ $status.innerText = this.i18n.t('noFileChosen');
+ $button.appendChild($status);
+ const commaSpan = document.createElement('span');
+ commaSpan.className = 'govuk-visually-hidden';
+ commaSpan.innerText = ', ';
+ commaSpan.id = `${fieldId}-comma`;
+ $button.appendChild(commaSpan);
+ const containerSpan = document.createElement('span');
+ containerSpan.className = 'govuk-file-upload-button__pseudo-button-container';
+ const buttonSpan = document.createElement('span');
+ buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload-button__pseudo-button';
+ buttonSpan.innerText = this.i18n.t('chooseFilesButton');
+ containerSpan.appendChild(buttonSpan);
+ containerSpan.insertAdjacentText('beforeend', ' ');
+ const instructionSpan = document.createElement('span');
+ instructionSpan.className = 'govuk-body govuk-file-upload-button__instruction';
+ instructionSpan.innerText = this.i18n.t('dropInstruction');
+ containerSpan.appendChild(instructionSpan);
+ $button.appendChild(containerSpan);
+ $button.setAttribute('aria-labelledby', `${$label.id} ${commaSpan.id} ${$button.id}`);
+ $button.addEventListener('click', this.onClick.bind(this));
+ $button.addEventListener('dragover', event => {
+ event.preventDefault();
+ });
+ this.$root.insertAdjacentElement('afterbegin', $button);
+ this.$button = $button;
+ this.$status = $status;
+ this.$input.addEventListener('change', this.onChange.bind(this));
+ this.synchroniseDisabledState();
+ 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.$root.insertAdjacentElement('afterend', this.$announcements);
+ this.$button.addEventListener('drop', this.onDrop.bind(this));
+ this.dropZone = new DropZone(this.$root, {
+ onEnter: this.onDropZoneEnter.bind(this),
+ onLeave: this.onDropZoneLeave.bind(this)
+ });
+ }
+
+ /**
+ * @returns {boolean} - Whether the component is disabled
+ */
+ get disabled() {
+ return this.$button.disabled;
+ }
+ set disabled(value) {
+ this.$button.disabled = value;
+ this.$root.classList.toggle('govuk-drop-zone--disabled', value);
+ }
+
+ /**
+ * @returns {boolean} Whether the user is dragging
+ */
+ get dragging() {
+ return this.$button.classList.contains('govuk-file-upload-button--dragging');
+ }
+
+ /**
+ * @param {boolean} value - Whether the user is dragging
+ */
+ set dragging(value) {
+ this.$button.classList.toggle('govuk-file-upload-button--dragging', value);
+ }
+
+ /**
+ * Shows the dropzone if user is not already dragging
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDropZoneEnter(event) {
+ if (this.disabled) return;
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.dragging) {
+ this.dragging = true;
+ this.$announcements.innerText = this.i18n.t('enteredDropZone');
+ }
+ }
+ }
+ onDropZoneLeave() {
+ if (this.disabled) return;
+ if (this.dragging) {
+ this.dragging = false;
+ this.$announcements.innerText = this.i18n.t('leftDropZone');
+ }
+ }
+
+ /**
+ * Handles user dropping on the component
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDrop(event) {
+ event.preventDefault();
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ this.$input.files = event.dataTransfer.files;
+ this.$input.dispatchEvent(new CustomEvent('change'));
+ this.dragging = false;
+ }
+ }
+ onChange() {
+ const fileCount = this.$input.files.length;
+ if (fileCount === 0) {
+ this.$status.innerText = this.i18n.t('noFileChosen');
+ this.$button.classList.add('govuk-file-upload-button--empty');
+ } else {
+ if (fileCount === 1) {
+ this.$status.innerText = this.$input.files[0].name;
+ } else {
+ this.$status.innerText = this.i18n.t('multipleFilesChosen', {
+ count: fileCount
+ });
+ }
+ this.$button.classList.remove('govuk-file-upload-button--empty');
+ }
+ }
+ findLabel(fieldId) {
+ const $label = document.querySelector(`label[for="${fieldId}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: `Field label (\`<label for=${fieldId}>\`)`
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$input.click();
+ }
+ synchroniseDisabledState() {
+ this.disabled = this.$input.disabled;
+ const observer = new MutationObserver(mutationList => {
+ for (const mutation of mutationList) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+ this.disabled = this.$input.disabled;
+ }
+ }
+ });
+ observer.observe(this.$input, {
+ attributes: true
+ });
+ }
+ }
+ FileUpload.moduleName = 'govuk-file-upload';
+ FileUpload.defaults = Object.freeze({
+ i18n: {
+ chooseFilesButton: 'Choose file',
+ dropInstruction: 'or drop file',
+ noFileChosen: 'No file chosen',
+ multipleFilesChosen: {
+ one: '%{count} file chosen',
+ other: '%{count} files chosen'
+ },
+ enteredDropZone: 'Entered drop zone',
+ leftDropZone: 'Left drop zone'
+ }
+ });
+ FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+ });
+ class DropZone {
+ /**
+ * @param {HTMLElement} $root - The root element of the dropzone
+ * @param {DropZoneOptions} options - The options for the dropzone
+ */
+ constructor($root, {
+ onEnter,
+ onLeave
+ }) {
+ this.$root = void 0;
+ this.onEnter = void 0;
+ this.onLeave = void 0;
+ this.$root = $root;
+ this.onEnter = onEnter;
+ this.onLeave = onLeave;
+ document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+ document.addEventListener('dragenter', () => {
+ this.enteredAnotherElement = true;
+ });
+ document.addEventListener('dragleave', event => {
+ if (!this.enteredAnotherElement) {
+ this.onLeave(event);
+ }
+ 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.$root.contains(event.target)) {
+ this.onEnter(event);
+ } else {
+ this.onLeave(event);
+ }
+ }
+ }
+ }
+
+ /**
+ * @typedef DropZoneOptions
+ * @property {(event: DragEvent) => void} onEnter - Callback invoked when user enters the dropzone while dragging
+ * @property {(event?: DragEvent) => void} onLeave - Callback invoked when user leaves the dropzone while dragging
+ */
+ 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} [chooseFile] - The text of the button that opens the file picker
+ * @property {string} [dropInstruction] - The text informing users they can drop files
+ * @property {TranslationPluralForms} [multipleFilesChosen] - The text displayed when multiple files
+ * have been chosen by the user
+ * @property {string} [noFileChosen] - The text to displayed when no file has been chosen by the user
+ * @property {string} [enteredDropZone] - The text announced by assistive technology
+ * when user drags files and enters the drop zone
+ * @property {string} [leftDropZone] - The text announced by assistive technology
+ * when user drags files and leaves the drop zone without dropping
+ */
+
+ /**
+ * @import { Schema } from '../../common/configuration.mjs'
+ * @import { TranslationPluralForms } from '../../i18n.mjs'
+ */
+
/**
* Header component
*
@@ -2443,7 +2753,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
@@ -2524,6 +2834,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
*/
@@ -2537,6 +2848,7 @@
* @import { ExitThisPageConfig } from './components/exit-this-page/exit-this-page.mjs'
* @import { NotificationBannerConfig } from './components/notification-banner/notification-banner.mjs'
* @import { PasswordInputConfig } from './components/password-input/password-input.mjs'
+ * @import { FileUploadConfig } from './components/file-upload/file-upload.mjs'
*/
/**
* Component config keys, e.g. `accordion` and `characterCount`
@@ -2575,6 +2887,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 adc70f031..82539da81 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -1656,6 +1656,316 @@ ExitThisPage.schema = Object.freeze({
}
});
+/**
+ * File upload component
+ *
+ * @preserve
+ * @augments ConfigurableComponent<FileUploadConfig>
+ */
+class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$input = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.i18n = void 0;
+ const $input = this.$root.querySelector('input');
+ if ($input === null) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File inputs (`<input type="file">`)'
+ });
+ }
+ if ($input.type !== 'file') {
+ throw new ElementError(formatErrorMessage(FileUpload, 'File input (`<input type="file">`) attribute (`type`) is not `file`'));
+ }
+ this.$input = $input;
+ this.$input.setAttribute('hidden', 'true');
+ const fieldId = this.$input.id;
+ if (!fieldId) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File input (`<input type="file">`) attribute (`id`)'
+ });
+ }
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.$root, 'lang')
+ });
+ const $label = this.findLabel(fieldId);
+ if (!$label.id) {
+ $label.id = `${fieldId}-label`;
+ }
+ this.$input.id = `${fieldId}-input`;
+ const $button = document.createElement('button');
+ $button.classList.add('govuk-file-upload-button');
+ $button.type = 'button';
+ $button.id = fieldId;
+ $button.classList.add('govuk-file-upload-button--empty');
+ const ariaDescribedBy = this.$input.getAttribute('aria-describedby');
+ if (ariaDescribedBy) {
+ $button.setAttribute('aria-describedby', ariaDescribedBy);
+ }
+ const $status = document.createElement('span');
+ $status.className = 'govuk-body govuk-file-upload-button__status';
+ $status.setAttribute('aria-live', 'polite');
+ $status.innerText = this.i18n.t('noFileChosen');
+ $button.appendChild($status);
+ const commaSpan = document.createElement('span');
+ commaSpan.className = 'govuk-visually-hidden';
+ commaSpan.innerText = ', ';
+ commaSpan.id = `${fieldId}-comma`;
+ $button.appendChild(commaSpan);
+ const containerSpan = document.createElement('span');
+ containerSpan.className = 'govuk-file-upload-button__pseudo-button-container';
+ const buttonSpan = document.createElement('span');
+ buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload-button__pseudo-button';
+ buttonSpan.innerText = this.i18n.t('chooseFilesButton');
+ containerSpan.appendChild(buttonSpan);
+ containerSpan.insertAdjacentText('beforeend', ' ');
+ const instructionSpan = document.createElement('span');
+ instructionSpan.className = 'govuk-body govuk-file-upload-button__instruction';
+ instructionSpan.innerText = this.i18n.t('dropInstruction');
+ containerSpan.appendChild(instructionSpan);
+ $button.appendChild(containerSpan);
+ $button.setAttribute('aria-labelledby', `${$label.id} ${commaSpan.id} ${$button.id}`);
+ $button.addEventListener('click', this.onClick.bind(this));
+ $button.addEventListener('dragover', event => {
+ event.preventDefault();
+ });
+ this.$root.insertAdjacentElement('afterbegin', $button);
+ this.$button = $button;
+ this.$status = $status;
+ this.$input.addEventListener('change', this.onChange.bind(this));
+ this.synchroniseDisabledState();
+ 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.$root.insertAdjacentElement('afterend', this.$announcements);
+ this.$button.addEventListener('drop', this.onDrop.bind(this));
+ this.dropZone = new DropZone(this.$root, {
+ onEnter: this.onDropZoneEnter.bind(this),
+ onLeave: this.onDropZoneLeave.bind(this)
+ });
+ }
+
+ /**
+ * @returns {boolean} - Whether the component is disabled
+ */
+ get disabled() {
+ return this.$button.disabled;
+ }
+ set disabled(value) {
+ this.$button.disabled = value;
+ this.$root.classList.toggle('govuk-drop-zone--disabled', value);
+ }
+
+ /**
+ * @returns {boolean} Whether the user is dragging
+ */
+ get dragging() {
+ return this.$button.classList.contains('govuk-file-upload-button--dragging');
+ }
+
+ /**
+ * @param {boolean} value - Whether the user is dragging
+ */
+ set dragging(value) {
+ this.$button.classList.toggle('govuk-file-upload-button--dragging', value);
+ }
+
+ /**
+ * Shows the dropzone if user is not already dragging
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDropZoneEnter(event) {
+ if (this.disabled) return;
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.dragging) {
+ this.dragging = true;
+ this.$announcements.innerText = this.i18n.t('enteredDropZone');
+ }
+ }
+ }
+ onDropZoneLeave() {
+ if (this.disabled) return;
+ if (this.dragging) {
+ this.dragging = false;
+ this.$announcements.innerText = this.i18n.t('leftDropZone');
+ }
+ }
+
+ /**
+ * Handles user dropping on the component
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDrop(event) {
+ event.preventDefault();
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ this.$input.files = event.dataTransfer.files;
+ this.$input.dispatchEvent(new CustomEvent('change'));
+ this.dragging = false;
+ }
+ }
+ onChange() {
+ const fileCount = this.$input.files.length;
+ if (fileCount === 0) {
+ this.$status.innerText = this.i18n.t('noFileChosen');
+ this.$button.classList.add('govuk-file-upload-button--empty');
+ } else {
+ if (fileCount === 1) {
+ this.$status.innerText = this.$input.files[0].name;
+ } else {
+ this.$status.innerText = this.i18n.t('multipleFilesChosen', {
+ count: fileCount
+ });
+ }
+ this.$button.classList.remove('govuk-file-upload-button--empty');
+ }
+ }
+ findLabel(fieldId) {
+ const $label = document.querySelector(`label[for="${fieldId}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: `Field label (\`<label for=${fieldId}>\`)`
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$input.click();
+ }
+ synchroniseDisabledState() {
+ this.disabled = this.$input.disabled;
+ const observer = new MutationObserver(mutationList => {
+ for (const mutation of mutationList) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+ this.disabled = this.$input.disabled;
+ }
+ }
+ });
+ observer.observe(this.$input, {
+ attributes: true
+ });
+ }
+}
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+ i18n: {
+ chooseFilesButton: 'Choose file',
+ dropInstruction: 'or drop file',
+ noFileChosen: 'No file chosen',
+ multipleFilesChosen: {
+ one: '%{count} file chosen',
+ other: '%{count} files chosen'
+ },
+ enteredDropZone: 'Entered drop zone',
+ leftDropZone: 'Left drop zone'
+ }
+});
+FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+});
+class DropZone {
+ /**
+ * @param {HTMLElement} $root - The root element of the dropzone
+ * @param {DropZoneOptions} options - The options for the dropzone
+ */
+ constructor($root, {
+ onEnter,
+ onLeave
+ }) {
+ this.$root = void 0;
+ this.onEnter = void 0;
+ this.onLeave = void 0;
+ this.$root = $root;
+ this.onEnter = onEnter;
+ this.onLeave = onLeave;
+ document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+ document.addEventListener('dragenter', () => {
+ this.enteredAnotherElement = true;
+ });
+ document.addEventListener('dragleave', event => {
+ if (!this.enteredAnotherElement) {
+ this.onLeave(event);
+ }
+ 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.$root.contains(event.target)) {
+ this.onEnter(event);
+ } else {
+ this.onLeave(event);
+ }
+ }
+ }
+}
+
+/**
+ * @typedef DropZoneOptions
+ * @property {(event: DragEvent) => void} onEnter - Callback invoked when user enters the dropzone while dragging
+ * @property {(event?: DragEvent) => void} onLeave - Callback invoked when user leaves the dropzone while dragging
+ */
+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} [chooseFile] - The text of the button that opens the file picker
+ * @property {string} [dropInstruction] - The text informing users they can drop files
+ * @property {TranslationPluralForms} [multipleFilesChosen] - The text displayed when multiple files
+ * have been chosen by the user
+ * @property {string} [noFileChosen] - The text to displayed when no file has been chosen by the user
+ * @property {string} [enteredDropZone] - The text announced by assistive technology
+ * when user drags files and enters the drop zone
+ * @property {string} [leftDropZone] - The text announced by assistive technology
+ * when user drags files and leaves the drop zone without dropping
+ */
+
+/**
+ * @import { Schema } from '../../common/configuration.mjs'
+ * @import { TranslationPluralForms } from '../../i18n.mjs'
+ */
+
/**
* Header component
*
@@ -2437,7 +2747,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
@@ -2518,6 +2828,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
*/
@@ -2531,6 +2842,7 @@ function createAll(Component, config, createAllOptions) {
* @import { ExitThisPageConfig } from './components/exit-this-page/exit-this-page.mjs'
* @import { NotificationBannerConfig } from './components/notification-banner/notification-banner.mjs'
* @import { PasswordInputConfig } from './components/password-input/password-input.mjs'
+ * @import { FileUploadConfig } from './components/file-upload/file-upload.mjs'
*/
/**
* Component config keys, e.g. `accordion` and `characterCount`
@@ -2561,5 +2873,5 @@ function createAll(Component, config, createAllOptions) {
* @property {OnErrorCallback<ComponentClass>} [onError] - callback function if error throw by component on init
*/
-export { Accordion, Button, CharacterCount, Checkboxes, Component, ConfigurableComponent, ErrorSummary, ExitThisPage, Header, NotificationBanner, PasswordInput, Radios, ServiceNavigation, SkipLink, Tabs, createAll, initAll, isSupported, version };
+export { Accordion, Button, CharacterCount, Checkboxes, 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 12576aa7b..01ef03347 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..08c9705e5 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/_index.scss
@@ -3,6 +3,7 @@
@import "../label/index";
@include govuk-exports("govuk/component/file-upload") {
+ $file-upload-border-width: 2px;
$component-padding: govuk-spacing(1);
.govuk-file-upload {
@@ -46,6 +47,159 @@
cursor: not-allowed;
}
}
+
+ .govuk-drop-zone {
+ display: block;
+ position: relative;
+ z-index: 0;
+ background-color: $govuk-body-background-colour;
+ }
+
+ // required because disabling pointer events
+ // on the button means that the cursor style
+ // be applied on the button itself
+ .govuk-drop-zone--disabled {
+ cursor: not-allowed;
+ }
+
+ .govuk-file-upload-button__pseudo-button {
+ width: auto;
+ margin-right: govuk-spacing(2);
+ margin-bottom: $govuk-border-width-form-element + 1;
+ flex-shrink: 0;
+ }
+
+ .govuk-file-upload-button__instruction {
+ margin-top: govuk-spacing(2) - ($govuk-border-width-form-element + 1);
+ margin-bottom: 0;
+ text-align: left;
+ }
+
+ .govuk-file-upload-button__status {
+ display: block;
+ margin-bottom: govuk-spacing(2);
+ padding: govuk-spacing(3) govuk-spacing(2);
+ background-color: govuk-colour("white");
+ text-align: left;
+ }
+
+ // bugs documented with button using flex
+ // https://github.com/philipwalton/flexbugs#flexbug-9
+ // so we need a container here
+ .govuk-file-upload-button__pseudo-button-container {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ }
+
+ .govuk-file-upload-button {
+ width: 100%;
+ // align the padding to be same as notification banner and error summary accounting for the thicker borders
+ padding: (govuk-spacing(3) + $govuk-border-width - $file-upload-border-width);
+ border: $file-upload-border-width govuk-colour("mid-grey") solid;
+ background-color: govuk-colour("light-grey");
+ cursor: pointer;
+
+ @include govuk-media-query($from: tablet) {
+ padding: (govuk-spacing(4) + $govuk-border-width - $file-upload-border-width);
+ }
+
+ .govuk-file-upload-button__pseudo-button {
+ background-color: govuk-colour("white");
+ }
+
+ &:hover {
+ background-color: govuk-tint(govuk-colour("mid-grey"), 20%);
+
+ .govuk-file-upload-button__pseudo-button {
+ background-color: govuk-shade(govuk-colour("light-grey"), 10%);
+ }
+
+ .govuk-file-upload-button__status {
+ background-color: govuk-tint(govuk-colour("blue"), 80%);
+ }
+ }
+
+ &:active,
+ &:focus {
+ border: $file-upload-border-width solid govuk-colour("black");
+ outline: $govuk-focus-width solid $govuk-focus-colour;
+ // Ensure outline appears outside of the element
+ outline-offset: 0;
+ background-color: govuk-tint(govuk-colour("mid-grey"), 20%);
+ // Double the border by adding its width again. Use `box-shadow` for this
+ // instead of changing `border-width` - this is for consistency with
+ // components such as textarea where we avoid changing `border-width` as
+ // it will change the element size. Also, `outline` cannot be utilised
+ // here as it is already used for the yellow focus state.
+ box-shadow: inset 0 0 0 $govuk-border-width-form-element;
+
+ .govuk-file-upload-button__pseudo-button {
+ background-color: $govuk-focus-colour;
+ box-shadow: 0 2px 0 govuk-colour("black");
+ }
+
+ &:hover .govuk-file-upload-button__pseudo-button {
+ 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;
+ }
+ }
+
+ &:disabled {
+ pointer-events: none;
+ opacity: 0.5;
+ }
+ }
+
+ .govuk-file-upload-button--empty {
+ border-style: dashed;
+ background-color: govuk-colour("white");
+
+ .govuk-file-upload-button__pseudo-button {
+ background-color: govuk-colour("light-grey");
+ }
+
+ .govuk-file-upload-button__status {
+ color: govuk-shade(govuk-colour("blue"), 60%);
+ background-color: govuk-tint(govuk-colour("blue"), 70%);
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: govuk-colour("light-grey");
+
+ .govuk-file-upload-button__status {
+ background-color: govuk-tint(govuk-colour("blue"), 80%);
+ }
+ }
+ }
+
+ .govuk-file-upload-button--dragging {
+ border-style: solid;
+ border-color: govuk-colour("black");
+
+ // extra specificity to apply when
+ // empty
+ &.govuk-file-upload-button {
+ background-color: govuk-tint(govuk-colour("mid-grey"), 20%);
+ }
+
+ &.govuk-file-upload-button--empty {
+ background-color: govuk-colour("light-grey");
+ }
+
+ &.govuk-file-upload-button--empty:not(:disabled) .govuk-file-upload-button__status,
+ &.govuk-file-upload-button--empty .govuk-file-upload-button__pseudo-button {
+ background-color: govuk-colour("white");
+ }
+
+ .govuk-file-upload-button__pseudo-button {
+ background-color: govuk-shade(govuk-colour("light-grey"), 10%);
+ }
+ }
}
/*# 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..eb8d5e3de
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js
@@ -0,0 +1,805 @@
+(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
+ */
+ /**
+ * @import { ObjectNested } from './configuration.mjs'
+ */
+
+ 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';
+ }
+ }
+ /**
+ * @import { ComponentWithModuleName } from '../common/index.mjs'
+ */
+
+ class Component {
+ /**
+ * Returns the root element of the component
+ *
+ * @protected
+ * @returns {RootElementType} - the root element of component
+ */
+ get $root() {
+ return this._$root;
+ }
+ constructor($root) {
+ this._$root = void 0;
+ const childConstructor = this.constructor;
+ if (typeof childConstructor.moduleName !== 'string') {
+ throw new InitError(`\`moduleName\` not defined in component`);
+ }
+ if (!($root instanceof childConstructor.elementType)) {
+ throw new ElementError({
+ element: $root,
+ component: childConstructor,
+ identifier: 'Root element (`$root`)',
+ expectedType: childConstructor.elementType.name
+ });
+ } else {
+ this._$root = $root;
+ }
+ childConstructor.checkSupport();
+ this.checkInitialised();
+ const moduleName = childConstructor.moduleName;
+ this.$root.setAttribute(`data-${moduleName}-init`, '');
+ }
+ checkInitialised() {
+ const constructor = this.constructor;
+ const moduleName = constructor.moduleName;
+ if (moduleName && isInitialised(this.$root, moduleName)) {
+ throw new InitError(constructor);
+ }
+ }
+ static checkSupport() {
+ if (!isSupported()) {
+ throw new SupportError();
+ }
+ }
+ }
+
+ /**
+ * @typedef ChildClass
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+ */
+
+ /**
+ * @typedef {typeof Component & ChildClass} ChildClassConstructor
+ */
+ Component.elementType = HTMLElement;
+
+ const configOverride = Symbol.for('configOverride');
+ class ConfigurableComponent extends Component {
+ [configOverride](param) {
+ return {};
+ }
+
+ /**
+ * Returns the root element of the component
+ *
+ * @protected
+ * @returns {ConfigurationType} - the root element of component
+ */
+ get config() {
+ return this._config;
+ }
+ constructor($root, config) {
+ super($root);
+ this._config = void 0;
+ const childConstructor = this.constructor;
+ if (!isObject(childConstructor.defaults)) {
+ throw new ConfigError(formatErrorMessage(childConstructor, 'Config passed as parameter into constructor but no defaults defined'));
+ }
+ const datasetConfig = normaliseDataset(childConstructor, this._$root.dataset);
+ this._config = mergeConfigs(childConstructor.defaults, config != null ? config : {}, this[configOverride](datasetConfig), datasetConfig);
+ }
+ }
+ function normaliseString(value, property) {
+ const trimmedValue = value ? value.trim() : '';
+ let output;
+ let outputType = property == null ? void 0 : property.type;
+ if (!outputType) {
+ if (['true', 'false'].includes(trimmedValue)) {
+ outputType = 'boolean';
+ }
+ if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+ outputType = 'number';
+ }
+ }
+ switch (outputType) {
+ case 'boolean':
+ output = trimmedValue === 'true';
+ break;
+ case 'number':
+ output = Number(trimmedValue);
+ break;
+ default:
+ output = value;
+ }
+ return output;
+ }
+ function normaliseDataset(Component, dataset) {
+ if (!isObject(Component.schema)) {
+ throw new ConfigError(formatErrorMessage(Component, 'Config passed as parameter into constructor but no schema defined'));
+ }
+ const out = {};
+ const entries = Object.entries(Component.schema.properties);
+ for (const entry of entries) {
+ const [namespace, property] = entry;
+ const field = namespace.toString();
+ if (field in dataset) {
+ out[field] = normaliseString(dataset[field], property);
+ }
+ if ((property == null ? void 0 : property.type) === 'object') {
+ out[field] = extractConfigByNamespace(Component.schema, dataset, namespace);
+ }
+ }
+ return out;
+ }
+ function mergeConfigs(...configObjects) {
+ const formattedConfigObject = {};
+ for (const configObject of configObjects) {
+ for (const key of Object.keys(configObject)) {
+ const option = formattedConfigObject[key];
+ const override = configObject[key];
+ if (isObject(option) && isObject(override)) {
+ formattedConfigObject[key] = mergeConfigs(option, override);
+ } else {
+ formattedConfigObject[key] = override;
+ }
+ }
+ }
+ return formattedConfigObject;
+ }
+ function extractConfigByNamespace(schema, dataset, namespace) {
+ const property = schema.properties[namespace];
+ if ((property == null ? void 0 : property.type) !== 'object') {
+ return;
+ }
+ const newObject = {
+ [namespace]: {}
+ };
+ for (const [key, value] of Object.entries(dataset)) {
+ let current = newObject;
+ const keyParts = key.split('.');
+ for (const [index, name] of keyParts.entries()) {
+ if (isObject(current)) {
+ if (index < keyParts.length - 1) {
+ if (!isObject(current[name])) {
+ current[name] = {};
+ }
+ current = current[name];
+ } else if (key !== namespace) {
+ current[name] = normaliseString(value);
+ }
+ }
+ }
+ }
+ return newObject[namespace];
+ }
+ /**
+ * Schema for component config
+ *
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
+ * @typedef {object} Schema
+ * @property {Record<keyof ConfigurationType, SchemaProperty | undefined>} properties - Schema properties
+ * @property {SchemaCondition<ConfigurationType>[]} [anyOf] - List of schema conditions
+ */
+ /**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+ /**
+ * Schema condition for component config
+ *
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
+ * @typedef {object} SchemaCondition
+ * @property {(keyof ConfigurationType)[]} required - List of required config fields
+ * @property {string} errorMessage - Error message when required config fields not provided
+ */
+ /**
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested]
+ * @typedef ChildClass
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+ * @property {Schema<ConfigurationType>} [schema] - The schema of the component configuration
+ * @property {ConfigurationType} [defaults] - The default values of the configuration of the component
+ */
+ /**
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested]
+ * @typedef {typeof Component & ChildClass<ConfigurationType>} ChildClassConstructor<ConfigurationType>
+ */
+
+ 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>
+ */
+ class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$input = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.i18n = void 0;
+ const $input = this.$root.querySelector('input');
+ if ($input === null) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File inputs (`<input type="file">`)'
+ });
+ }
+ if ($input.type !== 'file') {
+ throw new ElementError(formatErrorMessage(FileUpload, 'File input (`<input type="file">`) attribute (`type`) is not `file`'));
+ }
+ this.$input = $input;
+ this.$input.setAttribute('hidden', 'true');
+ const fieldId = this.$input.id;
+ if (!fieldId) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File input (`<input type="file">`) attribute (`id`)'
+ });
+ }
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.$root, 'lang')
+ });
+ const $label = this.findLabel(fieldId);
+ if (!$label.id) {
+ $label.id = `${fieldId}-label`;
+ }
+ this.$input.id = `${fieldId}-input`;
+ const $button = document.createElement('button');
+ $button.classList.add('govuk-file-upload-button');
+ $button.type = 'button';
+ $button.id = fieldId;
+ $button.classList.add('govuk-file-upload-button--empty');
+ const ariaDescribedBy = this.$input.getAttribute('aria-describedby');
+ if (ariaDescribedBy) {
+ $button.setAttribute('aria-describedby', ariaDescribedBy);
+ }
+ const $status = document.createElement('span');
+ $status.className = 'govuk-body govuk-file-upload-button__status';
+ $status.setAttribute('aria-live', 'polite');
+ $status.innerText = this.i18n.t('noFileChosen');
+ $button.appendChild($status);
+ const commaSpan = document.createElement('span');
+ commaSpan.className = 'govuk-visually-hidden';
+ commaSpan.innerText = ', ';
+ commaSpan.id = `${fieldId}-comma`;
+ $button.appendChild(commaSpan);
+ const containerSpan = document.createElement('span');
+ containerSpan.className = 'govuk-file-upload-button__pseudo-button-container';
+ const buttonSpan = document.createElement('span');
+ buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload-button__pseudo-button';
+ buttonSpan.innerText = this.i18n.t('chooseFilesButton');
+ containerSpan.appendChild(buttonSpan);
+ containerSpan.insertAdjacentText('beforeend', ' ');
+ const instructionSpan = document.createElement('span');
+ instructionSpan.className = 'govuk-body govuk-file-upload-button__instruction';
+ instructionSpan.innerText = this.i18n.t('dropInstruction');
+ containerSpan.appendChild(instructionSpan);
+ $button.appendChild(containerSpan);
+ $button.setAttribute('aria-labelledby', `${$label.id} ${commaSpan.id} ${$button.id}`);
+ $button.addEventListener('click', this.onClick.bind(this));
+ $button.addEventListener('dragover', event => {
+ event.preventDefault();
+ });
+ this.$root.insertAdjacentElement('afterbegin', $button);
+ this.$button = $button;
+ this.$status = $status;
+ this.$input.addEventListener('change', this.onChange.bind(this));
+ this.synchroniseDisabledState();
+ 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.$root.insertAdjacentElement('afterend', this.$announcements);
+ this.$button.addEventListener('drop', this.onDrop.bind(this));
+ this.dropZone = new DropZone(this.$root, {
+ onEnter: this.onDropZoneEnter.bind(this),
+ onLeave: this.onDropZoneLeave.bind(this)
+ });
+ }
+
+ /**
+ * @returns {boolean} - Whether the component is disabled
+ */
+ get disabled() {
+ return this.$button.disabled;
+ }
+ set disabled(value) {
+ this.$button.disabled = value;
+ this.$root.classList.toggle('govuk-drop-zone--disabled', value);
+ }
+
+ /**
+ * @returns {boolean} Whether the user is dragging
+ */
+ get dragging() {
+ return this.$button.classList.contains('govuk-file-upload-button--dragging');
+ }
+
+ /**
+ * @param {boolean} value - Whether the user is dragging
+ */
+ set dragging(value) {
+ this.$button.classList.toggle('govuk-file-upload-button--dragging', value);
+ }
+
+ /**
+ * Shows the dropzone if user is not already dragging
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDropZoneEnter(event) {
+ if (this.disabled) return;
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.dragging) {
+ this.dragging = true;
+ this.$announcements.innerText = this.i18n.t('enteredDropZone');
+ }
+ }
+ }
+ onDropZoneLeave() {
+ if (this.disabled) return;
+ if (this.dragging) {
+ this.dragging = false;
+ this.$announcements.innerText = this.i18n.t('leftDropZone');
+ }
+ }
+
+ /**
+ * Handles user dropping on the component
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDrop(event) {
+ event.preventDefault();
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ this.$input.files = event.dataTransfer.files;
+ this.$input.dispatchEvent(new CustomEvent('change'));
+ this.dragging = false;
+ }
+ }
+ onChange() {
+ const fileCount = this.$input.files.length;
+ if (fileCount === 0) {
+ this.$status.innerText = this.i18n.t('noFileChosen');
+ this.$button.classList.add('govuk-file-upload-button--empty');
+ } else {
+ if (fileCount === 1) {
+ this.$status.innerText = this.$input.files[0].name;
+ } else {
+ this.$status.innerText = this.i18n.t('multipleFilesChosen', {
+ count: fileCount
+ });
+ }
+ this.$button.classList.remove('govuk-file-upload-button--empty');
+ }
+ }
+ findLabel(fieldId) {
+ const $label = document.querySelector(`label[for="${fieldId}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: `Field label (\`<label for=${fieldId}>\`)`
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$input.click();
+ }
+ synchroniseDisabledState() {
+ this.disabled = this.$input.disabled;
+ const observer = new MutationObserver(mutationList => {
+ for (const mutation of mutationList) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+ this.disabled = this.$input.disabled;
+ }
+ }
+ });
+ observer.observe(this.$input, {
+ attributes: true
+ });
+ }
+ }
+ FileUpload.moduleName = 'govuk-file-upload';
+ FileUpload.defaults = Object.freeze({
+ i18n: {
+ chooseFilesButton: 'Choose file',
+ dropInstruction: 'or drop file',
+ noFileChosen: 'No file chosen',
+ multipleFilesChosen: {
+ one: '%{count} file chosen',
+ other: '%{count} files chosen'
+ },
+ enteredDropZone: 'Entered drop zone',
+ leftDropZone: 'Left drop zone'
+ }
+ });
+ FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+ });
+ class DropZone {
+ /**
+ * @param {HTMLElement} $root - The root element of the dropzone
+ * @param {DropZoneOptions} options - The options for the dropzone
+ */
+ constructor($root, {
+ onEnter,
+ onLeave
+ }) {
+ this.$root = void 0;
+ this.onEnter = void 0;
+ this.onLeave = void 0;
+ this.$root = $root;
+ this.onEnter = onEnter;
+ this.onLeave = onLeave;
+ document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+ document.addEventListener('dragenter', () => {
+ this.enteredAnotherElement = true;
+ });
+ document.addEventListener('dragleave', event => {
+ if (!this.enteredAnotherElement) {
+ this.onLeave(event);
+ }
+ 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.$root.contains(event.target)) {
+ this.onEnter(event);
+ } else {
+ this.onLeave(event);
+ }
+ }
+ }
+ }
+
+ /**
+ * @typedef DropZoneOptions
+ * @property {(event: DragEvent) => void} onEnter - Callback invoked when user enters the dropzone while dragging
+ * @property {(event?: DragEvent) => void} onLeave - Callback invoked when user leaves the dropzone while dragging
+ */
+ 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} [chooseFile] - The text of the button that opens the file picker
+ * @property {string} [dropInstruction] - The text informing users they can drop files
+ * @property {TranslationPluralForms} [multipleFilesChosen] - The text displayed when multiple files
+ * have been chosen by the user
+ * @property {string} [noFileChosen] - The text to displayed when no file has been chosen by the user
+ * @property {string} [enteredDropZone] - The text announced by assistive technology
+ * when user drags files and enters the drop zone
+ * @property {string} [leftDropZone] - The text announced by assistive technology
+ * when user drags files and leaves the drop zone without dropping
+ */
+
+ /**
+ * @import { Schema } from '../../common/configuration.mjs'
+ * @import { TranslationPluralForms } from '../../i18n.mjs'
+ */
+
+ 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..b3368628d
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs
@@ -0,0 +1,797 @@
+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
+ */
+/**
+ * @import { ObjectNested } from './configuration.mjs'
+ */
+
+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';
+ }
+}
+/**
+ * @import { ComponentWithModuleName } from '../common/index.mjs'
+ */
+
+class Component {
+ /**
+ * Returns the root element of the component
+ *
+ * @protected
+ * @returns {RootElementType} - the root element of component
+ */
+ get $root() {
+ return this._$root;
+ }
+ constructor($root) {
+ this._$root = void 0;
+ const childConstructor = this.constructor;
+ if (typeof childConstructor.moduleName !== 'string') {
+ throw new InitError(`\`moduleName\` not defined in component`);
+ }
+ if (!($root instanceof childConstructor.elementType)) {
+ throw new ElementError({
+ element: $root,
+ component: childConstructor,
+ identifier: 'Root element (`$root`)',
+ expectedType: childConstructor.elementType.name
+ });
+ } else {
+ this._$root = $root;
+ }
+ childConstructor.checkSupport();
+ this.checkInitialised();
+ const moduleName = childConstructor.moduleName;
+ this.$root.setAttribute(`data-${moduleName}-init`, '');
+ }
+ checkInitialised() {
+ const constructor = this.constructor;
+ const moduleName = constructor.moduleName;
+ if (moduleName && isInitialised(this.$root, moduleName)) {
+ throw new InitError(constructor);
+ }
+ }
+ static checkSupport() {
+ if (!isSupported()) {
+ throw new SupportError();
+ }
+ }
+}
+
+/**
+ * @typedef ChildClass
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+ */
+
+/**
+ * @typedef {typeof Component & ChildClass} ChildClassConstructor
+ */
+Component.elementType = HTMLElement;
+
+const configOverride = Symbol.for('configOverride');
+class ConfigurableComponent extends Component {
+ [configOverride](param) {
+ return {};
+ }
+
+ /**
+ * Returns the root element of the component
+ *
+ * @protected
+ * @returns {ConfigurationType} - the root element of component
+ */
+ get config() {
+ return this._config;
+ }
+ constructor($root, config) {
+ super($root);
+ this._config = void 0;
+ const childConstructor = this.constructor;
+ if (!isObject(childConstructor.defaults)) {
+ throw new ConfigError(formatErrorMessage(childConstructor, 'Config passed as parameter into constructor but no defaults defined'));
+ }
+ const datasetConfig = normaliseDataset(childConstructor, this._$root.dataset);
+ this._config = mergeConfigs(childConstructor.defaults, config != null ? config : {}, this[configOverride](datasetConfig), datasetConfig);
+ }
+}
+function normaliseString(value, property) {
+ const trimmedValue = value ? value.trim() : '';
+ let output;
+ let outputType = property == null ? void 0 : property.type;
+ if (!outputType) {
+ if (['true', 'false'].includes(trimmedValue)) {
+ outputType = 'boolean';
+ }
+ if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+ outputType = 'number';
+ }
+ }
+ switch (outputType) {
+ case 'boolean':
+ output = trimmedValue === 'true';
+ break;
+ case 'number':
+ output = Number(trimmedValue);
+ break;
+ default:
+ output = value;
+ }
+ return output;
+}
+function normaliseDataset(Component, dataset) {
+ if (!isObject(Component.schema)) {
+ throw new ConfigError(formatErrorMessage(Component, 'Config passed as parameter into constructor but no schema defined'));
+ }
+ const out = {};
+ const entries = Object.entries(Component.schema.properties);
+ for (const entry of entries) {
+ const [namespace, property] = entry;
+ const field = namespace.toString();
+ if (field in dataset) {
+ out[field] = normaliseString(dataset[field], property);
+ }
+ if ((property == null ? void 0 : property.type) === 'object') {
+ out[field] = extractConfigByNamespace(Component.schema, dataset, namespace);
+ }
+ }
+ return out;
+}
+function mergeConfigs(...configObjects) {
+ const formattedConfigObject = {};
+ for (const configObject of configObjects) {
+ for (const key of Object.keys(configObject)) {
+ const option = formattedConfigObject[key];
+ const override = configObject[key];
+ if (isObject(option) && isObject(override)) {
+ formattedConfigObject[key] = mergeConfigs(option, override);
+ } else {
+ formattedConfigObject[key] = override;
+ }
+ }
+ }
+ return formattedConfigObject;
+}
+function extractConfigByNamespace(schema, dataset, namespace) {
+ const property = schema.properties[namespace];
+ if ((property == null ? void 0 : property.type) !== 'object') {
+ return;
+ }
+ const newObject = {
+ [namespace]: {}
+ };
+ for (const [key, value] of Object.entries(dataset)) {
+ let current = newObject;
+ const keyParts = key.split('.');
+ for (const [index, name] of keyParts.entries()) {
+ if (isObject(current)) {
+ if (index < keyParts.length - 1) {
+ if (!isObject(current[name])) {
+ current[name] = {};
+ }
+ current = current[name];
+ } else if (key !== namespace) {
+ current[name] = normaliseString(value);
+ }
+ }
+ }
+ }
+ return newObject[namespace];
+}
+/**
+ * Schema for component config
+ *
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
+ * @typedef {object} Schema
+ * @property {Record<keyof ConfigurationType, SchemaProperty | undefined>} properties - Schema properties
+ * @property {SchemaCondition<ConfigurationType>[]} [anyOf] - List of schema conditions
+ */
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+/**
+ * Schema condition for component config
+ *
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} ConfigurationType
+ * @typedef {object} SchemaCondition
+ * @property {(keyof ConfigurationType)[]} required - List of required config fields
+ * @property {string} errorMessage - Error message when required config fields not provided
+ */
+/**
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested]
+ * @typedef ChildClass
+ * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
+ * @property {Schema<ConfigurationType>} [schema] - The schema of the component configuration
+ * @property {ConfigurationType} [defaults] - The default values of the configuration of the component
+ */
+/**
+ * @template {Partial<Record<keyof ConfigurationType, unknown>>} [ConfigurationType=ObjectNested]
+ * @typedef {typeof Component & ChildClass<ConfigurationType>} ChildClassConstructor<ConfigurationType>
+ */
+
+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>
+ */
+class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$input = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.i18n = void 0;
+ const $input = this.$root.querySelector('input');
+ if ($input === null) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File inputs (`<input type="file">`)'
+ });
+ }
+ if ($input.type !== 'file') {
+ throw new ElementError(formatErrorMessage(FileUpload, 'File input (`<input type="file">`) attribute (`type`) is not `file`'));
+ }
+ this.$input = $input;
+ this.$input.setAttribute('hidden', 'true');
+ const fieldId = this.$input.id;
+ if (!fieldId) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File input (`<input type="file">`) attribute (`id`)'
+ });
+ }
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.$root, 'lang')
+ });
+ const $label = this.findLabel(fieldId);
+ if (!$label.id) {
+ $label.id = `${fieldId}-label`;
+ }
+ this.$input.id = `${fieldId}-input`;
+ const $button = document.createElement('button');
+ $button.classList.add('govuk-file-upload-button');
+ $button.type = 'button';
+ $button.id = fieldId;
+ $button.classList.add('govuk-file-upload-button--empty');
+ const ariaDescribedBy = this.$input.getAttribute('aria-describedby');
+ if (ariaDescribedBy) {
+ $button.setAttribute('aria-describedby', ariaDescribedBy);
+ }
+ const $status = document.createElement('span');
+ $status.className = 'govuk-body govuk-file-upload-button__status';
+ $status.setAttribute('aria-live', 'polite');
+ $status.innerText = this.i18n.t('noFileChosen');
+ $button.appendChild($status);
+ const commaSpan = document.createElement('span');
+ commaSpan.className = 'govuk-visually-hidden';
+ commaSpan.innerText = ', ';
+ commaSpan.id = `${fieldId}-comma`;
+ $button.appendChild(commaSpan);
+ const containerSpan = document.createElement('span');
+ containerSpan.className = 'govuk-file-upload-button__pseudo-button-container';
+ const buttonSpan = document.createElement('span');
+ buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload-button__pseudo-button';
+ buttonSpan.innerText = this.i18n.t('chooseFilesButton');
+ containerSpan.appendChild(buttonSpan);
+ containerSpan.insertAdjacentText('beforeend', ' ');
+ const instructionSpan = document.createElement('span');
+ instructionSpan.className = 'govuk-body govuk-file-upload-button__instruction';
+ instructionSpan.innerText = this.i18n.t('dropInstruction');
+ containerSpan.appendChild(instructionSpan);
+ $button.appendChild(containerSpan);
+ $button.setAttribute('aria-labelledby', `${$label.id} ${commaSpan.id} ${$button.id}`);
+ $button.addEventListener('click', this.onClick.bind(this));
+ $button.addEventListener('dragover', event => {
+ event.preventDefault();
+ });
+ this.$root.insertAdjacentElement('afterbegin', $button);
+ this.$button = $button;
+ this.$status = $status;
+ this.$input.addEventListener('change', this.onChange.bind(this));
+ this.synchroniseDisabledState();
+ 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.$root.insertAdjacentElement('afterend', this.$announcements);
+ this.$button.addEventListener('drop', this.onDrop.bind(this));
+ this.dropZone = new DropZone(this.$root, {
+ onEnter: this.onDropZoneEnter.bind(this),
+ onLeave: this.onDropZoneLeave.bind(this)
+ });
+ }
+
+ /**
+ * @returns {boolean} - Whether the component is disabled
+ */
+ get disabled() {
+ return this.$button.disabled;
+ }
+ set disabled(value) {
+ this.$button.disabled = value;
+ this.$root.classList.toggle('govuk-drop-zone--disabled', value);
+ }
+
+ /**
+ * @returns {boolean} Whether the user is dragging
+ */
+ get dragging() {
+ return this.$button.classList.contains('govuk-file-upload-button--dragging');
+ }
+
+ /**
+ * @param {boolean} value - Whether the user is dragging
+ */
+ set dragging(value) {
+ this.$button.classList.toggle('govuk-file-upload-button--dragging', value);
+ }
+
+ /**
+ * Shows the dropzone if user is not already dragging
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDropZoneEnter(event) {
+ if (this.disabled) return;
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.dragging) {
+ this.dragging = true;
+ this.$announcements.innerText = this.i18n.t('enteredDropZone');
+ }
+ }
+ }
+ onDropZoneLeave() {
+ if (this.disabled) return;
+ if (this.dragging) {
+ this.dragging = false;
+ this.$announcements.innerText = this.i18n.t('leftDropZone');
+ }
+ }
+
+ /**
+ * Handles user dropping on the component
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDrop(event) {
+ event.preventDefault();
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ this.$input.files = event.dataTransfer.files;
+ this.$input.dispatchEvent(new CustomEvent('change'));
+ this.dragging = false;
+ }
+ }
+ onChange() {
+ const fileCount = this.$input.files.length;
+ if (fileCount === 0) {
+ this.$status.innerText = this.i18n.t('noFileChosen');
+ this.$button.classList.add('govuk-file-upload-button--empty');
+ } else {
+ if (fileCount === 1) {
+ this.$status.innerText = this.$input.files[0].name;
+ } else {
+ this.$status.innerText = this.i18n.t('multipleFilesChosen', {
+ count: fileCount
+ });
+ }
+ this.$button.classList.remove('govuk-file-upload-button--empty');
+ }
+ }
+ findLabel(fieldId) {
+ const $label = document.querySelector(`label[for="${fieldId}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: `Field label (\`<label for=${fieldId}>\`)`
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$input.click();
+ }
+ synchroniseDisabledState() {
+ this.disabled = this.$input.disabled;
+ const observer = new MutationObserver(mutationList => {
+ for (const mutation of mutationList) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+ this.disabled = this.$input.disabled;
+ }
+ }
+ });
+ observer.observe(this.$input, {
+ attributes: true
+ });
+ }
+}
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+ i18n: {
+ chooseFilesButton: 'Choose file',
+ dropInstruction: 'or drop file',
+ noFileChosen: 'No file chosen',
+ multipleFilesChosen: {
+ one: '%{count} file chosen',
+ other: '%{count} files chosen'
+ },
+ enteredDropZone: 'Entered drop zone',
+ leftDropZone: 'Left drop zone'
+ }
+});
+FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+});
+class DropZone {
+ /**
+ * @param {HTMLElement} $root - The root element of the dropzone
+ * @param {DropZoneOptions} options - The options for the dropzone
+ */
+ constructor($root, {
+ onEnter,
+ onLeave
+ }) {
+ this.$root = void 0;
+ this.onEnter = void 0;
+ this.onLeave = void 0;
+ this.$root = $root;
+ this.onEnter = onEnter;
+ this.onLeave = onLeave;
+ document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+ document.addEventListener('dragenter', () => {
+ this.enteredAnotherElement = true;
+ });
+ document.addEventListener('dragleave', event => {
+ if (!this.enteredAnotherElement) {
+ this.onLeave(event);
+ }
+ 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.$root.contains(event.target)) {
+ this.onEnter(event);
+ } else {
+ this.onLeave(event);
+ }
+ }
+ }
+}
+
+/**
+ * @typedef DropZoneOptions
+ * @property {(event: DragEvent) => void} onEnter - Callback invoked when user enters the dropzone while dragging
+ * @property {(event?: DragEvent) => void} onLeave - Callback invoked when user leaves the dropzone while dragging
+ */
+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} [chooseFile] - The text of the button that opens the file picker
+ * @property {string} [dropInstruction] - The text informing users they can drop files
+ * @property {TranslationPluralForms} [multipleFilesChosen] - The text displayed when multiple files
+ * have been chosen by the user
+ * @property {string} [noFileChosen] - The text to displayed when no file has been chosen by the user
+ * @property {string} [enteredDropZone] - The text announced by assistive technology
+ * when user drags files and enters the drop zone
+ * @property {string} [leftDropZone] - The text announced by assistive technology
+ * when user drags files and leaves the drop zone without dropping
+ */
+
+/**
+ * @import { Schema } from '../../common/configuration.mjs'
+ * @import { TranslationPluralForms } from '../../i18n.mjs'
+ */
+
+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..450fbc1f9
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
@@ -0,0 +1,318 @@
+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>
+ */
+class FileUpload extends ConfigurableComponent {
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config);
+ this.$input = void 0;
+ this.$button = void 0;
+ this.$status = void 0;
+ this.i18n = void 0;
+ const $input = this.$root.querySelector('input');
+ if ($input === null) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File inputs (`<input type="file">`)'
+ });
+ }
+ if ($input.type !== 'file') {
+ throw new ElementError(formatErrorMessage(FileUpload, 'File input (`<input type="file">`) attribute (`type`) is not `file`'));
+ }
+ this.$input = $input;
+ this.$input.setAttribute('hidden', 'true');
+ const fieldId = this.$input.id;
+ if (!fieldId) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File input (`<input type="file">`) attribute (`id`)'
+ });
+ }
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.$root, 'lang')
+ });
+ const $label = this.findLabel(fieldId);
+ if (!$label.id) {
+ $label.id = `${fieldId}-label`;
+ }
+ this.$input.id = `${fieldId}-input`;
+ const $button = document.createElement('button');
+ $button.classList.add('govuk-file-upload-button');
+ $button.type = 'button';
+ $button.id = fieldId;
+ $button.classList.add('govuk-file-upload-button--empty');
+ const ariaDescribedBy = this.$input.getAttribute('aria-describedby');
+ if (ariaDescribedBy) {
+ $button.setAttribute('aria-describedby', ariaDescribedBy);
+ }
+ const $status = document.createElement('span');
+ $status.className = 'govuk-body govuk-file-upload-button__status';
+ $status.setAttribute('aria-live', 'polite');
+ $status.innerText = this.i18n.t('noFileChosen');
+ $button.appendChild($status);
+ const commaSpan = document.createElement('span');
+ commaSpan.className = 'govuk-visually-hidden';
+ commaSpan.innerText = ', ';
+ commaSpan.id = `${fieldId}-comma`;
+ $button.appendChild(commaSpan);
+ const containerSpan = document.createElement('span');
+ containerSpan.className = 'govuk-file-upload-button__pseudo-button-container';
+ const buttonSpan = document.createElement('span');
+ buttonSpan.className = 'govuk-button govuk-button--secondary govuk-file-upload-button__pseudo-button';
+ buttonSpan.innerText = this.i18n.t('chooseFilesButton');
+ containerSpan.appendChild(buttonSpan);
+ containerSpan.insertAdjacentText('beforeend', ' ');
+ const instructionSpan = document.createElement('span');
+ instructionSpan.className = 'govuk-body govuk-file-upload-button__instruction';
+ instructionSpan.innerText = this.i18n.t('dropInstruction');
+ containerSpan.appendChild(instructionSpan);
+ $button.appendChild(containerSpan);
+ $button.setAttribute('aria-labelledby', `${$label.id} ${commaSpan.id} ${$button.id}`);
+ $button.addEventListener('click', this.onClick.bind(this));
+ $button.addEventListener('dragover', event => {
+ event.preventDefault();
+ });
+ this.$root.insertAdjacentElement('afterbegin', $button);
+ this.$button = $button;
+ this.$status = $status;
+ this.$input.addEventListener('change', this.onChange.bind(this));
+ this.synchroniseDisabledState();
+ 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.$root.insertAdjacentElement('afterend', this.$announcements);
+ this.$button.addEventListener('drop', this.onDrop.bind(this));
+ this.dropZone = new DropZone(this.$root, {
+ onEnter: this.onDropZoneEnter.bind(this),
+ onLeave: this.onDropZoneLeave.bind(this)
+ });
+ }
+
+ /**
+ * @returns {boolean} - Whether the component is disabled
+ */
+ get disabled() {
+ return this.$button.disabled;
+ }
+ set disabled(value) {
+ this.$button.disabled = value;
+ this.$root.classList.toggle('govuk-drop-zone--disabled', value);
+ }
+
+ /**
+ * @returns {boolean} Whether the user is dragging
+ */
+ get dragging() {
+ return this.$button.classList.contains('govuk-file-upload-button--dragging');
+ }
+
+ /**
+ * @param {boolean} value - Whether the user is dragging
+ */
+ set dragging(value) {
+ this.$button.classList.toggle('govuk-file-upload-button--dragging', value);
+ }
+
+ /**
+ * Shows the dropzone if user is not already dragging
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDropZoneEnter(event) {
+ if (this.disabled) return;
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ if (!this.dragging) {
+ this.dragging = true;
+ this.$announcements.innerText = this.i18n.t('enteredDropZone');
+ }
+ }
+ }
+ onDropZoneLeave() {
+ if (this.disabled) return;
+ if (this.dragging) {
+ this.dragging = false;
+ this.$announcements.innerText = this.i18n.t('leftDropZone');
+ }
+ }
+
+ /**
+ * Handles user dropping on the component
+ *
+ * @param {DragEvent} event - The `dragenter` event
+ */
+ onDrop(event) {
+ event.preventDefault();
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ this.$input.files = event.dataTransfer.files;
+ this.$input.dispatchEvent(new CustomEvent('change'));
+ this.dragging = false;
+ }
+ }
+ onChange() {
+ const fileCount = this.$input.files.length;
+ if (fileCount === 0) {
+ this.$status.innerText = this.i18n.t('noFileChosen');
+ this.$button.classList.add('govuk-file-upload-button--empty');
+ } else {
+ if (fileCount === 1) {
+ this.$status.innerText = this.$input.files[0].name;
+ } else {
+ this.$status.innerText = this.i18n.t('multipleFilesChosen', {
+ count: fileCount
+ });
+ }
+ this.$button.classList.remove('govuk-file-upload-button--empty');
+ }
+ }
+ findLabel(fieldId) {
+ const $label = document.querySelector(`label[for="${fieldId}"]`);
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: `Field label (\`<label for=${fieldId}>\`)`
+ });
+ }
+ return $label;
+ }
+ onClick() {
+ this.$input.click();
+ }
+ synchroniseDisabledState() {
+ this.disabled = this.$input.disabled;
+ const observer = new MutationObserver(mutationList => {
+ for (const mutation of mutationList) {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
+ this.disabled = this.$input.disabled;
+ }
+ }
+ });
+ observer.observe(this.$input, {
+ attributes: true
+ });
+ }
+}
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+ i18n: {
+ chooseFilesButton: 'Choose file',
+ dropInstruction: 'or drop file',
+ noFileChosen: 'No file chosen',
+ multipleFilesChosen: {
+ one: '%{count} file chosen',
+ other: '%{count} files chosen'
+ },
+ enteredDropZone: 'Entered drop zone',
+ leftDropZone: 'Left drop zone'
+ }
+});
+FileUpload.schema = Object.freeze({
+ properties: {
+ i18n: {
+ type: 'object'
+ }
+ }
+});
+class DropZone {
+ /**
+ * @param {HTMLElement} $root - The root element of the dropzone
+ * @param {DropZoneOptions} options - The options for the dropzone
+ */
+ constructor($root, {
+ onEnter,
+ onLeave
+ }) {
+ this.$root = void 0;
+ this.onEnter = void 0;
+ this.onLeave = void 0;
+ this.$root = $root;
+ this.onEnter = onEnter;
+ this.onLeave = onLeave;
+ document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+ document.addEventListener('dragenter', () => {
+ this.enteredAnotherElement = true;
+ });
+ document.addEventListener('dragleave', event => {
+ if (!this.enteredAnotherElement) {
+ this.onLeave(event);
+ }
+ 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.$root.contains(event.target)) {
+ this.onEnter(event);
+ } else {
+ this.onLeave(event);
+ }
+ }
+ }
+}
+
+/**
+ * @typedef DropZoneOptions
+ * @property {(event: DragEvent) => void} onEnter - Callback invoked when user enters the dropzone while dragging
+ * @property {(event?: DragEvent) => void} onLeave - Callback invoked when user leaves the dropzone while dragging
+ */
+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} [chooseFile] - The text of the button that opens the file picker
+ * @property {string} [dropInstruction] - The text informing users they can drop files
+ * @property {TranslationPluralForms} [multipleFilesChosen] - The text displayed when multiple files
+ * have been chosen by the user
+ * @property {string} [noFileChosen] - The text to displayed when no file has been chosen by the user
+ * @property {string} [enteredDropZone] - The text announced by assistive technology
+ * when user drags files and enters the drop zone
+ * @property {string} [leftDropZone] - The text announced by assistive technology
+ * when user drags files and leaves the drop zone without dropping
+ */
+
+/**
+ * @import { Schema } from '../../common/configuration.mjs'
+ * @import { TranslationPluralForms } from '../../i18n.mjs'
+ */
+
+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 0da13f2e0..a3753586c 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/fixtures.json
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/fixtures.json
@@ -15,6 +15,74 @@
"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>"
},
+ {
+ "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\" 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\" 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\" 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\" disabled>\n</div>"
+ },
{
"name": "with hint text",
"options": {
@@ -55,55 +123,139 @@
"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>"
},
{
- "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\">\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\">\n</div>"
},
{
- "name": "with optional form-group classes",
+ "name": "enhanced",
"options": {
"id": "file-upload-1",
"name": "file-upload-1",
"label": {
"text": "Upload a file"
},
- "formGroup": {
- "classes": "extra-class"
+ "javascript": true
+ },
+ "hidden": false,
+ "description": "",
+ "previewLayoutModifiers": [],
+ "screenshot": true,
+ "html": "<div class=\"govuk-form-group\">\n <label class=\"govuk-label\" for=\"file-upload-1\">\n Upload a file\n </label>\n <div\n class=\"govuk-drop-zone\"\n data-module=\"govuk-file-upload\">\n <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\">\n </div>\n</div>"
+ },
+ {
+ "name": "enhanced, disabled",
+ "options": {
+ "javascript": true,
+ "disabled": true,
+ "id": "file-upload-error",
+ "name": "file-upload-error",
+ "label": {
+ "text": "Upload a file"
+ },
+ "hint": {
+ "text": "Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto."
}
},
"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-error\">\n Upload a file\n </label>\n <div id=\"file-upload-error-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 <div\n class=\"govuk-drop-zone\"\n data-module=\"govuk-file-upload\">\n <input class=\"govuk-file-upload\" id=\"file-upload-error\" name=\"file-upload-error\" type=\"file\" disabled aria-describedby=\"file-upload-error-hint\">\n </div>\n</div>"
+ },
+ {
+ "name": "enhanced, with error message and hint",
+ "options": {
+ "javascript": true,
+ "id": "file-upload-3",
+ "name": "file-upload-3",
+ "label": {
+ "text": "Upload a file"
+ },
+ "hint": {
+ "text": "Your photo may be in your Pictures, Photos, Downloads or Desktop folder. Or in an app like iPhoto."
+ },
+ "errorMessage": {
+ "text": "Error message goes here"
+ }
+ },
+ "hidden": false,
+ "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 <div\n class=\"govuk-drop-zone\"\n data-module=\"govuk-file-upload\">\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>\n</div>"
+ },
+ {
+ "name": "translated",
+ "options": {
+ "id": "file-upload-1",
+ "name": "file-upload-1",
+ "label": {
+ "text": "Llwythwch ffeil i fyny"
+ },
+ "multiple": true,
+ "javascript": true,
+ "chooseFilesButtonText": "Dewiswch ffeil",
+ "dropInstructionText": "neu ollwng ffeil",
+ "noFileChosenText": "Dim ffeil wedi'i dewis",
+ "multipleFilesChosenText": {
+ "other": "%{count} ffeil wedi'u dewis",
+ "one": "%{count} ffeil wedi'i dewis"
+ },
+ "enteredDropZoneText": "Wedi mynd i mewn i'r parth gollwng",
+ "leftDropZoneText": "Parth gollwng i'r chwith"
+ },
+ "hidden": false,
+ "description": "",
+ "previewLayoutModifiers": [],
+ "screenshot": false,
+ "html": "<div class=\"govuk-form-group\">\n <label class=\"govuk-label\" for=\"file-upload-1\">\n Llwythwch ffeil i fyny\n </label>\n <div\n class=\"govuk-drop-zone\"\n data-module=\"govuk-file-upload\" data-i18n.choose-files-button=\"Dewiswch ffeil\" data-i18n.no-file-chosen=\"Dim ffeil wedi'i dewis\" data-i18n.multiple-files-chosen.other=\"%{count} ffeil wedi'u dewis\" data-i18n.multiple-files-chosen.one=\"%{count} ffeil wedi'i dewis\" data-i18n.drop-instruction=\"neu ollwng ffeil\" data-i18n.entered-drop-zone=\"Wedi mynd i mewn i'r parth gollwng\" data-i18n.left-drop-zone=\"Parth gollwng i'r chwith\">\n <input class=\"govuk-file-upload\" id=\"file-upload-1\" name=\"file-upload-1\" type=\"file\" multiple>\n </div>\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\" value=\"C:\fakepath\myphoto.jpg\">\n</div>"
},
{
"name": "attributes",
@@ -247,6 +399,31 @@
"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>"
+ },
+ {
+ "name": "translated, no javascript enhancement",
+ "options": {
+ "id": "file-upload-1",
+ "name": "file-upload-1",
+ "label": {
+ "text": "Llwythwch ffeil i fyny"
+ },
+ "multiple": true,
+ "chooseFilesButtonText": "Dewiswch ffeil",
+ "dropInstructionText": "neu ollwng ffeil",
+ "noFileChosenText": "Dim ffeil wedi'i dewis",
+ "multipleFilesChosenText": {
+ "other": "%{count} ffeil wedi'u dewis",
+ "one": "%{count} ffeil wedi'i dewis"
+ },
+ "enteredDropZoneText": "Wedi mynd i mewn i'r parth gollwng",
+ "leftDropZoneText": "Parth gollwng i'r chwith"
+ },
+ "hidden": true,
+ "description": "",
+ "previewLayoutModifiers": [],
+ "screenshot": false,
+ "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\" multiple>\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 a9eff4d8c..b1e5f033b 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,48 @@
}
]
},
+ {
+ "name": "javascript",
+ "type": "boolean",
+ "required": false,
+ "description": "Can be used to enable JavaScript enhancements for the component."
+ },
+ {
+ "name": "chooseFilesButtonText",
+ "type": "string",
+ "required": false,
+ "description": "The text of the button that opens the file picker. Default is `\"Choose file\"`. If `javascript` is not provided, this option will be ignored."
+ },
+ {
+ "name": "dropInstructionText",
+ "type": "string",
+ "required": false,
+ "description": "The text informing users they can drop files. Default is `\"or drop file\"`. If `javascript` is not provided, this option will be ignored."
+ },
+ {
+ "name": "multipleFilesChosenText",
+ "type": "object",
+ "required": false,
+ "description": "The text displayed when multiple files have been chosen by the user. The component will replace the `%{count}` placeholder with the number of files selected. [Our pluralisation rules apply to this macro option](https://frontend.design-system.service.gov.uk/localise-govuk-frontend/#understanding-pluralisation-rules). If `javascript` is not provided, this option will be ignored."
+ },
+ {
+ "name": "noFileChosenText",
+ "type": "string",
+ "required": false,
+ "description": "The text displayed when no file has been chosen by the user. Default is `\"No file chosen\"`. If `javascript` is not provided, this option will be ignored."
+ },
+ {
+ "name": "enteredDropZoneText",
+ "type": "string",
+ "required": false,
+ "description": "The text announced by assistive technology when user drags files and enters the drop zone. Default is `\"Entered drop zone\"`. If `javascript` is not provided, this option will be ignored."
+ },
+ {
+ "name": "leftDropZoneText",
+ "type": "string",
+ "required": false,
+ "description": "The text announced by assistive technology when user drags files and leaves the drop zone without dropping. Default is `\"Left drop zone\"`. If `javascript` is not provided, this option will be ignored."
+ },
{
"name": "classes",
"type": "string",
diff --git a/packages/govuk-frontend/dist/govuk/init.mjs b/packages/govuk-frontend/dist/govuk/init.mjs
index 375a62cd7..30d3ff973 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
*/
@@ -129,6 +131,7 @@ function createAll(Component, config, createAllOptions) {
* @import { ExitThisPageConfig } from './components/exit-this-page/exit-this-page.mjs'
* @import { NotificationBannerConfig } from './components/notification-banner/notification-banner.mjs'
* @import { PasswordInputConfig } from './components/password-input/password-input.mjs'
+ * @import { FileUploadConfig } from './components/file-upload/file-upload.mjs'
*/
/**
* Component config keys, e.g. `accordion` and `characterCount`
Action run for d7f212d |
Base automatically changed from
file-upload-tidy-ups
to
spike-enhanced-file-upload
February 27, 2025 10:57
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.
Note
This can be merged after 5.9.0 ships as it's all internal, non-breaking changes
Moving code around without changing functionality of the component to improve the code structure.
Each commit focuses on one change and has a little explanation.
Last thing that'd be neat to update is the creation of the button. Wondering if it can be encapsulated in a function that takes all the 'variable' parts of the button, generates the HTML of the button using a string rather than
createElement
calls. That HTML could then be injected viainsertAdjacentHTML
and the$button
and$status
grabbed from the DOM. This would likely make it easier to understand the markup of the button at a glance, as well as reduce the code of theFileUpload
class constructor a little bit.