diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54a68ad0c9..70f9ccaeea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,54 @@ For advice on how to use these release notes see [our guidance on staying up to
### New features
+#### Use our improved File upload component
+
+We've added a [JavaScript enhancement to the File upload component](https://design-system.service.gov.uk/components/file-upload/#using-the-improved-file-upload-component) which:
+
+- makes the component easier to use for drag and drop
+- allows the text of the component to be translated
+- fixes accessibility issues for users of Dragon, a speech recognition software
+
+This improvement is opt-in, as it's a substantial visual change which risks shifting other content on the page.
+
+To enable this improvement for your users, you'll first need to update the markup of your File upload component:
+
+- if you use our Nunjucks macro, using the new `javascript` option of `govukFileUpload`
+
+ ```njk
+ {{ govukFileUpload({
+ id: "file-upload",
+ name: "photo",
+ label: {
+ text: "Upload your photo"
+ },
+ javascript: true
+ }) }}
+ ```
+
+- if you're using HTML, wrapping the ` ` of the File upload markup in a `
`
+
+ ```html
+
+ ```
+
+If you're importing components individually in your JavaScript, which we recommend for better performance, you'll then need to import and initialise the new `FileUpload` component.
+
+```js
+import {FileUpload} from 'govuk-frontend'
+
+createAll(FileUpload)
+```
+
+This change was introduced in [pull request #5305: Add progressively enhanced File Upload component](https://github.com/alphagov/govuk-frontend/pull/5305)
+
#### Form control components now have default `id` attributes
If you're using the included Nunjucks macros, the Text input, Textarea, Password input, Character count, File upload, and Select components now automatically use the value of the `name` parameter for the `id` parameter.
diff --git a/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk b/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk
index 196640ef9a..2cfdd32168 100644
--- a/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk
+++ b/packages/govuk-frontend-review/src/views/examples/error-summary/index.njk
@@ -49,6 +49,10 @@
"text": "Problem with file",
"href": "#file"
},
+ {
+ "text": "Problem with file (enhanced)",
+ "href": "#file-enhanced"
+ },
{
"text": "Problem with radios",
"href": "#radios"
@@ -192,6 +196,18 @@
}
}) }}
+ {{ govukFileUpload({
+ label: {
+ "text": 'Label for enhanced file upload'
+ },
+ id: "file-enhanced",
+ name: "file-enhanced",
+ errorMessage: {
+ "text": "Problem with file"
+ },
+ javascript: true
+ }) }}
+
{{ govukRadios({
fieldset: {
legend: {
diff --git a/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk b/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk
index f5183726f7..5697b1bd6b 100644
--- a/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk
+++ b/packages/govuk-frontend-review/src/views/examples/form-elements/index.njk
@@ -28,6 +28,7 @@
Date pattern
Select box
File upload
+
File upload (enhanced)
{% endblock %}
diff --git a/packages/govuk-frontend-review/src/views/examples/translated/index.njk b/packages/govuk-frontend-review/src/views/examples/translated/index.njk
index 3ba09e3c52..6e22722075 100644
--- a/packages/govuk-frontend-review/src/views/examples/translated/index.njk
+++ b/packages/govuk-frontend-review/src/views/examples/translated/index.njk
@@ -340,6 +340,24 @@
}
}) }}
+ {{ govukFileUpload({
+ id: "file-upload-1",
+ name: "file-upload-1",
+ label: {
+ text: "Llwythwch ffeil i fyny"
+ },
+ 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"
+ }) }}
+
{{ govukFooter({
classes: "govuk-!-margin-bottom-4",
navigation: [
diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk
index 244c8247b9..43716cba34 100644
--- a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk
+++ b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo-success/index.njk
@@ -69,7 +69,8 @@ scenario: |
hint: {
text: "Your photo must be at least 50KB and no more than 10MB"
},
- errorMessage: errors["photo"]
+ errorMessage: errors["photo"],
+ javascript: true
}) }}
{{ govukCheckboxes({
diff --git a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk
index 6612671c5e..c45e7b1986 100644
--- a/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk
+++ b/packages/govuk-frontend-review/src/views/full-page-examples/upload-your-photo/index.njk
@@ -71,7 +71,8 @@ scenario: |
hint: {
text: "Your photo must be at least 50KB and no more than 10MB"
},
- errorMessage: errors["photo"]
+ errorMessage: errors["photo"],
+ javascript: true
}) }}
{{ govukCheckboxes({
diff --git a/packages/govuk-frontend/src/govuk/all.mjs b/packages/govuk-frontend/src/govuk/all.mjs
index 3cc7ab2bcc..6eeff733a9 100644
--- a/packages/govuk-frontend/src/govuk/all.mjs
+++ b/packages/govuk-frontend/src/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/src/govuk/all.puppeteer.test.js b/packages/govuk-frontend/src/govuk/all.puppeteer.test.js
index 254fbaa26b..387449bec5 100644
--- a/packages/govuk-frontend/src/govuk/all.puppeteer.test.js
+++ b/packages/govuk-frontend/src/govuk/all.puppeteer.test.js
@@ -72,6 +72,7 @@ describe('GOV.UK Frontend', () => {
'ConfigurableComponent',
'ErrorSummary',
'ExitThisPage',
+ 'FileUpload',
'Header',
'NotificationBanner',
'PasswordInput',
diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss b/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss
index 5862ab9cc3..5f0dd039c8 100644
--- a/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss
+++ b/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss
@@ -3,7 +3,11 @@
@import "../label/index";
@include govuk-exports("govuk/component/file-upload") {
+ $file-upload-border-width: 2px;
$component-padding: govuk-spacing(1);
+ $empty-button-background-colour: govuk-colour("white");
+ $empty-pseudo-button-background-colour: govuk-colour("light-grey");
+ $empty-status-background-colour: govuk-tint(govuk-colour("blue"), 70%);
.govuk-file-upload {
@include govuk-font($size: 19);
@@ -46,4 +50,167 @@
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;
+ }
+ }
+ }
+
+ .govuk-file-upload-button--empty {
+ border-style: dashed;
+ background-color: $empty-button-background-colour;
+
+ .govuk-file-upload-button__pseudo-button {
+ background-color: $empty-pseudo-button-background-colour;
+ }
+
+ .govuk-file-upload-button__status {
+ color: govuk-shade(govuk-colour("blue"), 60%);
+ background-color: $empty-status-background-colour;
+ }
+
+ &: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%);
+ }
+ }
+
+ .govuk-file-upload-button:disabled {
+ pointer-events: none;
+ opacity: 0.5;
+
+ background-color: $empty-button-background-colour;
+
+ .govuk-file-upload-button__pseudo-button {
+ background-color: $empty-pseudo-button-background-colour;
+ }
+
+ .govuk-file-upload-button__status {
+ background-color: $empty-status-background-colour;
+ }
+ }
}
diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs b/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs
index 33f590b06a..1a410b6dd5 100644
--- a/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs
+++ b/packages/govuk-frontend/src/govuk/components/file-upload/accessibility.puppeteer.test.mjs
@@ -7,9 +7,37 @@ describe('/components/file-upload', () => {
const examples = await getExamples('file-upload')
for (const exampleName in examples) {
- await render(page, 'file-upload', examples[exampleName])
+ // JavaScript enhancements being optional, some examples will not have
+ // any element with `data-module="govuk-file-upload"`. This causes an error
+ // as `render` assumes that if a component is exported by GOV.UK Frontend
+ // its rendered markup will have a `data-module` and tried to initialise
+ // the JavaScript component, even if no element with the right `data-module`
+ // is on the page.
+ //
+ // Because of this, we need to filter `ElementError` thrown by the JavaScript
+ // component to examples that actually run the JavaScript enhancements
+ try {
+ await render(page, 'file-upload', examples[exampleName])
+ } catch (e) {
+ const macroOptions = /** @type {MacroOptions} */ (
+ examples[exampleName].context
+ )
+
+ const exampleUsesJavaScript = macroOptions.javascript
+ const exampleLackedRoot = e.message.includes(
+ 'govuk-file-upload: Root element'
+ )
+
+ if (!exampleLackedRoot || exampleUsesJavaScript) {
+ throw e
+ }
+ }
await expect(axe(page)).resolves.toHaveNoViolations()
}
}, 120000)
})
})
+
+/**
+ * @import {MacroOptions} from '@govuk-frontend/lib/components'
+ */
diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs
new file mode 100644
index 0000000000..6e1a8f9ac3
--- /dev/null
+++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs
@@ -0,0 +1,469 @@
+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
+ */
+export class FileUpload extends ConfigurableComponent {
+ /**
+ * @private
+ * @type {HTMLFileInputElement}
+ */
+ $input
+
+ /**
+ * @private
+ */
+ $button
+
+ /**
+ * @private
+ */
+ $status
+
+ /** @private */
+ i18n
+
+ /** @private */
+ id
+
+ /**
+ * @param {Element | null} $root - File input element
+ * @param {FileUploadConfig} [config] - File Upload config
+ */
+ constructor($root, config = {}) {
+ super($root, config)
+
+ const $input = this.$root.querySelector('input')
+
+ if ($input === null) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File inputs (` `)'
+ })
+ }
+
+ if ($input.type !== 'file') {
+ throw new ElementError(
+ formatErrorMessage(
+ FileUpload,
+ 'File input (` `) attribute (`type`) is not `file`'
+ )
+ )
+ }
+
+ this.$input = /** @type {HTMLFileInputElement} */ ($input)
+ this.$input.setAttribute('hidden', 'true')
+
+ if (!this.$input.id) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: 'File input (` `) attribute (`id`)'
+ })
+ }
+
+ this.id = this.$input.id
+
+ this.i18n = new I18n(this.config.i18n, {
+ // Read the fallback if necessary rather than have it set in the defaults
+ locale: closestAttributeValue(this.$root, 'lang')
+ })
+
+ const $label = this.findLabel()
+ // Add an ID to the label if it doesn't have one already
+ // so it can be referenced by `aria-labelledby`
+ if (!$label.id) {
+ $label.id = `${this.id}-label`
+ }
+
+ // we need to copy the 'id' of the root element
+ // to the new button replacement element
+ // so that focus will work in the error summary
+ this.$input.id = `${this.id}-input`
+
+ // Create the file selection button
+ const $button = document.createElement('button')
+ $button.classList.add('govuk-file-upload-button')
+ $button.type = 'button'
+ $button.id = this.id
+ $button.classList.add('govuk-file-upload-button--empty')
+
+ // Copy `aria-describedby` if present so hints and errors
+ // are associated to the ``
+ const ariaDescribedBy = this.$input.getAttribute('aria-describedby')
+ if (ariaDescribedBy) {
+ $button.setAttribute('aria-describedby', ariaDescribedBy)
+ }
+
+ // Create status element that shows what/how many files are selected
+ 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 = `${this.id}-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)
+
+ // Add a space so the button and instruction read correctly
+ // when CSS is disabled
+ 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) => {
+ // prevent default to allow drop
+ event.preventDefault()
+ })
+
+ // Assemble these all together
+ this.$root.insertAdjacentElement('afterbegin', $button)
+
+ this.$input.setAttribute('tabindex', '-1')
+ this.$input.setAttribute('aria-hidden', 'true')
+
+ // Make all these new variables available to the module
+ this.$button = $button
+ this.$status = $status
+
+ // Bind change event to the underlying input
+ this.$input.addEventListener('change', this.onChange.bind(this))
+
+ // Synchronise the `disabled` state between the button and underlying input
+ this.updateDisabledState()
+ this.observeDisabledState()
+
+ // Handle drop zone visibility
+ // A live region to announce when users enter or leave the drop zone
+ 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)
+
+ // if there is no CSS and input is hidden
+ // button will need to handle drop event
+ this.$button.addEventListener('drop', this.onDrop.bind(this))
+
+ // While user is dragging, it gets a little more complex because of Safari.
+ // Safari doesn't fill `relatedTarget` on `dragleave` (nor `dragenter`).
+ // This means we can't use `relatedTarget` to:
+ // - check if the user is still within the wrapper
+ // (`relatedTarget` being a descendant of the wrapper)
+ // - check if the user is still over the viewport
+ // (`relatedTarget` being null if outside)
+
+ // Thanks to `dragenter` bubbling, we can listen on the `document` with a
+ // single function and update the visibility based on whether we entered a
+ // node inside or outside the wrapper.
+ document.addEventListener(
+ 'dragenter',
+ this.updateDropzoneVisibility.bind(this)
+ )
+
+ // To detect if we're outside the document, we can track if there was a
+ // `dragenter` event preceding a `dragleave`. If there wasn't, this means
+ // we're outside the document.
+ //
+ // The order of events is guaranteed by the HTML specs:
+ // https://html.spec.whatwg.org/multipage/dnd.html#drag-and-drop-processing-model
+ document.addEventListener('dragenter', () => {
+ this.enteredAnotherElement = true
+ })
+
+ document.addEventListener('dragleave', () => {
+ if (!this.enteredAnotherElement && !this.$button.disabled) {
+ this.hideDraggingState()
+ this.$announcements.innerText = this.i18n.t('leftDropZone')
+ }
+
+ 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 (this.$button.disabled) return
+
+ // DOM interfaces only type `event.target` as `EventTarget`
+ // so we first need to make sure it's a `Node`
+ if (event.target instanceof Node) {
+ if (this.$root.contains(event.target)) {
+ if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+ // Only update the class and make the announcement if not already visible
+ // to avoid repeated announcements on NVDA (2024.4) + Firefox (133)
+ if (
+ !this.$button.classList.contains(
+ 'govuk-file-upload-button--dragging'
+ )
+ ) {
+ this.showDraggingState()
+ this.$announcements.innerText = this.i18n.t('enteredDropZone')
+ }
+ }
+ } else {
+ // Only hide the dropzone if it is visible to prevent announcing user
+ // left the drop zone when they enter the page but haven't reached yet
+ // the file upload component
+ if (
+ this.$button.classList.contains('govuk-file-upload-button--dragging')
+ ) {
+ this.hideDraggingState()
+ this.$announcements.innerText = this.i18n.t('leftDropZone')
+ }
+ }
+ }
+ }
+
+ /**
+ * Show the drop zone visually
+ */
+ showDraggingState() {
+ this.$button.classList.add('govuk-file-upload-button--dragging')
+ }
+
+ /**
+ * Hides the drop zone visually
+ */
+ hideDraggingState() {
+ this.$button.classList.remove('govuk-file-upload-button--dragging')
+ }
+
+ /**
+ * 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
+
+ // Dispatch a `change` event so external code that would rely on the ` `
+ // dispatching an event when files are dropped still work.
+ // Use a `CustomEvent` so our events are distinguishable from browser's native events
+ this.$input.dispatchEvent(new CustomEvent('change'))
+
+ this.hideDraggingState()
+ }
+ }
+
+ /**
+ * Check if the value of the underlying input has changed
+ */
+ onChange() {
+ const fileCount = this.$input.files.length
+
+ if (fileCount === 0) {
+ // If there are no files, show the default selection text
+ this.$status.innerText = this.i18n.t('noFileChosen')
+ this.$button.classList.add('govuk-file-upload-button--empty')
+ } else {
+ if (
+ // If there is 1 file, just show the file name
+ fileCount === 1
+ ) {
+ this.$status.innerText = this.$input.files[0].name
+ } else {
+ // Otherwise, tell the user how many files are selected
+ this.$status.innerText = this.i18n.t('multipleFilesChosen', {
+ count: fileCount
+ })
+ }
+
+ this.$button.classList.remove('govuk-file-upload-button--empty')
+ }
+ }
+
+ /**
+ * Looks up the `` element associated to the field
+ *
+ * @private
+ * @returns {HTMLElement} The `` element associated to the field
+ * @throws {ElementError} If the `` cannot be found
+ */
+ findLabel() {
+ // Use `label` in the selector so TypeScript knows the type fo `HTMLElement`
+ const $label = document.querySelector(`label[for="${this.$input.id}"]`)
+
+ if (!$label) {
+ throw new ElementError({
+ component: FileUpload,
+ identifier: `Field label (\`\`)`
+ })
+ }
+
+ return $label
+ }
+
+ /**
+ * When the button is clicked, emulate clicking the actual, hidden file input
+ */
+ onClick() {
+ this.$input.click()
+ }
+
+ /**
+ * Create a mutation observer to check if the input's attributes altered.
+ */
+ observeDisabledState() {
+ const observer = new MutationObserver((mutationList) => {
+ for (const mutation of mutationList) {
+ if (
+ mutation.type === 'attributes' &&
+ mutation.attributeName === 'disabled'
+ ) {
+ this.updateDisabledState()
+ }
+ }
+ })
+
+ observer.observe(this.$input, {
+ attributes: true
+ })
+ }
+
+ /**
+ * Synchronise the `disabled` state between the input and replacement button.
+ */
+ updateDisabledState() {
+ this.$button.disabled = this.$input.disabled
+
+ this.$root.classList.toggle(
+ 'govuk-drop-zone--disabled',
+ this.$button.disabled
+ )
+ }
+
+ /**
+ * Name for the component used when initialising using data-module attributes.
+ */
+ static moduleName = 'govuk-file-upload'
+
+ /**
+ * File upload default config
+ *
+ * @see {@link FileUploadConfig}
+ * @constant
+ * @type {FileUploadConfig}
+ */
+ static defaults = Object.freeze({
+ i18n: {
+ chooseFilesButton: 'Choose file',
+ dropInstruction: 'or drop file',
+ noFileChosen: 'No file chosen',
+ multipleFilesChosen: {
+ // the 'one' string isn't used as the component displays the filename
+ // instead, however it's here for coverage's sake
+ one: '%{count} file chosen',
+ other: '%{count} files chosen'
+ },
+ enteredDropZone: 'Entered drop zone',
+ leftDropZone: 'Left drop zone'
+ }
+ })
+
+ /**
+ * File upload config schema
+ *
+ * @constant
+ * @satisfies {Schema}
+ */
+ static schema = Object.freeze({
+ properties: {
+ i18n: { type: 'object' }
+ }
+ })
+}
+
+/**
+ * Checks if the given `DataTransfer` contains files
+ *
+ * @internal
+ * @param {DataTransfer} dataTransfer - The `DataTransfer` to check
+ * @returns {boolean} - `true` if it contains files or we can't infer it, `false` otherwise
+ */
+function isContainingFiles(dataTransfer) {
+ // Safari sometimes does not provide info about types :'(
+ // In which case best not to assume anything and try to set the files
+ const hasNoTypesInfo = dataTransfer.types.length === 0
+
+ // When dragging images, there's a mix of mime types + Files
+ // which we can't assign to the native input
+ 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'
+ */
diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js
new file mode 100644
index 0000000000..f16abeb522
--- /dev/null
+++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js
@@ -0,0 +1,728 @@
+/* eslint-disable no-new */
+
+const {
+ render,
+ getAccessibleName
+} = require('@govuk-frontend/helpers/puppeteer')
+const { getExamples } = require('@govuk-frontend/lib/components')
+
+const inputSelector = '.govuk-file-upload'
+const wrapperSelector = '.govuk-drop-zone'
+const buttonSelector = '.govuk-file-upload-button'
+const statusSelector = '.govuk-file-upload-button__status'
+const pseudoButtonSelector = '.govuk-file-upload-button__pseudo-button'
+
+describe('/components/file-upload', () => {
+ let examples
+
+ beforeAll(async () => {
+ examples = await getExamples('file-upload')
+ })
+
+ describe('/components/file-upload/preview', () => {
+ describe('when JavaScript is unavailable or fails', () => {
+ beforeAll(async () => {
+ await page.setJavaScriptEnabled(false)
+ })
+
+ afterAll(async () => {
+ await page.setJavaScriptEnabled(true)
+ })
+
+ it('renders an unmodified file input', async () => {
+ await render(page, 'file-upload', examples.default)
+
+ const inputType = await page.$eval(inputSelector, (el) =>
+ el.getAttribute('type')
+ )
+ expect(inputType).toBe('file')
+ })
+
+ it('does not inject additional elements', async () => {
+ await render(page, 'file-upload', examples.default)
+
+ const $wrapperElement = await page.$(wrapperSelector)
+ const $buttonElement = await page.$(buttonSelector)
+ const $statusElement = await page.$(statusSelector)
+
+ expect($wrapperElement).toBeNull()
+ expect($buttonElement).toBeNull()
+ expect($statusElement).toBeNull()
+ })
+ })
+
+ describe('when JavaScript is available', () => {
+ describe('on page load', () => {
+ beforeAll(async () => {
+ await render(page, 'file-upload', examples.enhanced)
+ })
+
+ describe('wrapper element', () => {
+ it('renders the wrapper element', async () => {
+ const wrapperElement = await page.$eval(wrapperSelector, (el) => el)
+
+ expect(wrapperElement).toBeDefined()
+ })
+
+ it('moves the file input inside of the wrapper element', async () => {
+ const inputElementParent = await page.$eval(
+ inputSelector,
+ (el) => el.parentNode
+ )
+ const wrapperElement = await page.$eval(wrapperSelector, (el) => el)
+
+ expect(inputElementParent).toStrictEqual(wrapperElement)
+ })
+ })
+
+ describe('file input', () => {
+ it('sets tabindex to -1', async () => {
+ const inputElementTabindex = await page.$eval(inputSelector, (el) =>
+ el.getAttribute('tabindex')
+ )
+
+ expect(inputElementTabindex).toBe('-1')
+ })
+ })
+
+ describe('label element', () => {
+ it('targets the button in its `for` attribute', async () => {
+ const buttonId = await page.$eval(buttonSelector, (el) => el.id)
+ const label = await page.$(`[for="${buttonId}"]`)
+
+ expect(label).not.toBeNull()
+ })
+ })
+
+ describe('choose file button', () => {
+ it('renders the button element', async () => {
+ const buttonElement = await page.$eval(buttonSelector, (el) => el)
+ const buttonElementType = await page.$eval(buttonSelector, (el) =>
+ el.getAttribute('type')
+ )
+
+ expect(buttonElement).toBeDefined()
+ expect(buttonElementType).toBe('button')
+ })
+
+ it('renders the button with default text', async () => {
+ const buttonElementText = await page.$eval(
+ `${buttonSelector} ${pseudoButtonSelector}`,
+ (el) => el.innerHTML.trim()
+ )
+
+ const [statusElementText, statusElementAriaLiveAttribute] =
+ await page.$eval(`${buttonSelector} ${statusSelector}`, (el) => [
+ el.innerHTML.trim(),
+ el.getAttribute('aria-live')
+ ])
+
+ expect(buttonElementText).toBe('Choose file')
+ expect(statusElementText).toBe('No file chosen')
+ expect(statusElementAriaLiveAttribute).toBe('polite')
+ })
+ })
+ })
+
+ describe('when clicking the choose file button', () => {
+ it.each([buttonSelector, pseudoButtonSelector, statusSelector])(
+ 'opens the file picker',
+ async (selector) => {
+ // It doesn't seem to be possible to check if the file picker dialog
+ // opens as an isolated test, so this test clicks the button, tries to
+ // set a value in the file chooser, then checks if that value was set
+ // on the input as expected.
+ const testFilename = 'test.gif'
+ await render(page, 'file-upload', examples.enhanced)
+
+ const [fileChooser] = await Promise.all([
+ page.waitForFileChooser(),
+ page.click(selector)
+ ])
+
+ await fileChooser.accept([testFilename])
+
+ const inputElementValue = await page.$eval(
+ inputSelector,
+ (el) =>
+ // @ts-ignore
+ el.value
+ )
+
+ // For Windows and backward compatibility, the values of file inputs
+ // are always formatted starting with `C:\\fakepath\\`
+ expect(inputElementValue).toBe(`C:\\fakepath\\${testFilename}`)
+ }
+ )
+ })
+
+ describe('when selecting a file', () => {
+ const testFilename = 'fakefile.txt'
+
+ beforeEach(async () => {
+ await render(page, 'file-upload', examples.enhanced)
+
+ const [fileChooser] = await Promise.all([
+ page.waitForFileChooser(),
+ page.click(buttonSelector)
+ ])
+ await fileChooser.accept([testFilename])
+ })
+
+ it('updates the file input value', async () => {
+ const inputElementValue = await page.$eval(
+ inputSelector,
+ (el) =>
+ // @ts-ignore
+ el.value
+ )
+
+ const inputElementFiles = await page.$eval(
+ inputSelector,
+ (el) =>
+ // @ts-ignore
+ el.files
+ )
+
+ // For Windows and backward compatibility, the values of file inputs
+ // are always formatted starting with `C:\\fakepath\\`
+ expect(inputElementValue).toBe(`C:\\fakepath\\${testFilename}`)
+
+ // Also check the files object
+ expect(inputElementFiles[0]).toBeDefined()
+ })
+
+ it('updates the filename in the status element', async () => {
+ const statusElementText = await page.$eval(statusSelector, (el) =>
+ el.innerHTML.trim()
+ )
+
+ expect(statusElementText).toBe(testFilename)
+ })
+ })
+
+ describe('when selecting multiple files', () => {
+ beforeEach(async () => {
+ await render(page, 'file-upload', examples.enhanced, {
+ beforeInitialisation() {
+ document
+ .querySelector('[type="file"]')
+ .setAttribute('multiple', '')
+ }
+ })
+
+ const [fileChooser] = await Promise.all([
+ page.waitForFileChooser(),
+ page.click(buttonSelector)
+ ])
+ await fileChooser.accept(['testfile1.txt', 'testfile2.pdf'])
+ })
+
+ it('updates the file input value', async () => {
+ const inputElementValue = await page.$eval(
+ inputSelector,
+ (el) =>
+ // @ts-ignore
+ el.value
+ )
+
+ const inputElementFiles = await page.$eval(
+ inputSelector,
+ (el) =>
+ // @ts-ignore
+ el.files
+ )
+
+ // For Windows and backward compatibility, the values of file inputs
+ // are always formatted starting with `C:\\fakepath\\`
+ //
+ // Additionally, `value` will only ever return the first file selected
+ expect(inputElementValue).toBe(`C:\\fakepath\\testfile1.txt`)
+
+ // Also check the files object
+ expect(inputElementFiles[0]).toBeDefined()
+ expect(inputElementFiles[1]).toBeDefined()
+ })
+
+ it('shows the number of files selected in the status element', async () => {
+ const statusElementText = await page.$eval(statusSelector, (el) =>
+ el.innerHTML.trim()
+ )
+
+ expect(statusElementText).toBe('2 files chosen')
+ })
+ })
+
+ describe('dropzone', () => {
+ let $wrapper
+ let $announcements
+ let wrapperBoundingBox
+
+ // Shared data to drag on the element
+ const dragData = {
+ items: [],
+ files: [__filename],
+ dragOperationsMask: 1 // Copy
+ }
+
+ const selectorDropzoneVisible =
+ '.govuk-file-upload-button.govuk-file-upload-button--dragging'
+ const selectorDropzoneHidden =
+ '.govuk-file-upload-button:not(.govuk-file-upload-button--dragging)'
+
+ beforeEach(async () => {
+ await render(page, 'file-upload', examples.enhanced)
+
+ $wrapper = await page.$('.govuk-drop-zone')
+ wrapperBoundingBox = await $wrapper.boundingBox()
+
+ $announcements = await page.$('.govuk-file-upload-announcements')
+ })
+
+ it('is not shown by default', async () => {
+ await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
+
+ const [announcementsText, announcementsAriaLive] =
+ await $announcements.evaluate((e) => [
+ e.textContent,
+ e.getAttribute('aria-live')
+ ])
+
+ expect(announcementsText).toBe('')
+ // As the announcement is feedback while user is dragging,
+ // best to announce it as soon as the user enters the zone
+ expect(announcementsAriaLive).toBe('assertive')
+ })
+
+ it('gets shown when entering the field', async () => {
+ // Add a little pixel to make sure we're effectively within the element
+ await page.mouse.dragEnter(
+ { x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
+ structuredClone(dragData)
+ )
+
+ await expect(page.$(selectorDropzoneVisible)).resolves.toBeTruthy()
+ await expect(
+ $announcements.evaluate((e) => e.textContent)
+ ).resolves.toBe('Entered drop zone')
+ })
+
+ it('gets hidden when dropping on the field', async () => {
+ // Puppeteer's Mouse.drop is meant to do both the `dragEnter` and
+ // `drop` in a row but it seems to do this too quickly for the
+ // ` ` to effectively receive the drop
+ await page.mouse.dragEnter(
+ { x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
+ structuredClone(dragData)
+ )
+
+ await page.mouse.drop(
+ { x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
+ structuredClone(dragData)
+ )
+
+ await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
+ // The presence of 'Entered drop zone' confirms we entered the drop zone
+ // rather than being in the initial state
+ await expect(
+ $announcements.evaluate((e) => e.textContent)
+ ).resolves.toBe('Entered drop zone')
+ })
+
+ it('gets hidden when dragging a file and leaving the field', async () => {
+ // Add a little pixel to make sure we're effectively within the element
+ await page.mouse.dragEnter(
+ { x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
+ structuredClone(dragData)
+ )
+
+ // Move enough to the left to be out of the wrapper properly
+ // but not up or down in case there's other elements in the flow of the page
+ await page.mouse.dragEnter(
+ { x: wrapperBoundingBox.x - 20, y: wrapperBoundingBox.y },
+ structuredClone(dragData)
+ )
+
+ await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
+ await expect(
+ $announcements.evaluate((e) => e.textContent)
+ ).resolves.toBe('Left drop zone')
+ })
+
+ it('gets hidden when dragging a file and leaving the document', async () => {
+ // Add a little pixel to make sure we're effectively within the element
+ await page.mouse.dragEnter(
+ { x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
+ structuredClone(dragData)
+ )
+
+ // It doesn't seem doable to make Puppeteer drag outside the viewport
+ // so instead, we can only mock two 'dragleave' events
+ await page.$eval('.govuk-drop-zone', ($el) => {
+ $el.dispatchEvent(new Event('dragleave', { bubbles: true }))
+ $el.dispatchEvent(new Event('dragleave', { bubbles: true }))
+ })
+
+ await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
+ await expect(
+ $announcements.evaluate((e) => e.textContent)
+ ).resolves.toBe('Left drop zone')
+ })
+
+ it('does not appear if button disabled', async () => {
+ await render(page, 'file-upload', examples.enhanced, {
+ beforeInitialisation() {
+ document
+ .querySelector('[type="file"]')
+ .setAttribute('disabled', '')
+ }
+ })
+
+ await page.mouse.dragEnter(
+ { x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
+ structuredClone(dragData)
+ )
+
+ const disabledAnnouncement = await page.$(
+ '.govuk-file-upload-announcements'
+ )
+
+ await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
+ await expect(
+ disabledAnnouncement.evaluate((e) => e.textContent)
+ ).resolves.toBe('')
+ })
+ })
+
+ describe('accessible name', () => {
+ beforeEach(async () => {})
+
+ it('includes the label, the status, the pseudo button and instruction', async () => {
+ await render(page, 'file-upload', examples.enhanced)
+
+ const $element = await page.$('.govuk-file-upload-button')
+
+ const accessibleName = await getAccessibleName(page, $element)
+ await expect(accessibleName.replaceAll(/\s+/g, ' ')).toBe(
+ 'Upload a file , No file chosen , Choose file or drop file'
+ )
+ })
+
+ it('includes the label, file name, pseudo button and instruction once a file is selected', async () => {
+ await render(page, 'file-upload', examples.enhanced)
+
+ const $element = await page.$('.govuk-file-upload-button')
+
+ const [fileChooser] = await Promise.all([
+ page.waitForFileChooser(),
+ page.click(buttonSelector)
+ ])
+ await fileChooser.accept(['fakefile.txt'])
+
+ const accessibleName = await getAccessibleName(page, $element)
+ await expect(accessibleName.replaceAll(/\s+/g, ' ')).toBe(
+ 'Upload a file , fakefile.txt , Choose file or drop file'
+ )
+ })
+
+ it('includes the label, file name, pseudo button and instruction once a file is selected', async () => {
+ await render(page, 'file-upload', examples.enhanced, {
+ beforeInitialisation() {
+ document
+ .querySelector('[type="file"]')
+ .setAttribute('multiple', '')
+ }
+ })
+
+ const $element = await page.$('.govuk-file-upload-button')
+
+ const [fileChooser] = await Promise.all([
+ page.waitForFileChooser(),
+ page.click(buttonSelector)
+ ])
+ await fileChooser.accept(['fakefile1.txt', 'fakefile2.txt'])
+
+ const accessibleName = await getAccessibleName(page, $element)
+ await expect(accessibleName.replaceAll(/\s+/g, ' ')).toBe(
+ 'Upload a file , 2 files chosen , Choose file or drop file'
+ )
+ })
+ })
+
+ describe('i18n', () => {
+ beforeEach(async () => {
+ await render(page, 'file-upload', examples.translated)
+ })
+
+ it('uses the correct translation for the choose file button', async () => {
+ const buttonElementText = await page.$eval(
+ pseudoButtonSelector,
+ (el) => el.innerHTML.trim()
+ )
+
+ const statusElementText = await page.$eval(statusSelector, (el) =>
+ el.innerHTML.trim()
+ )
+
+ expect(buttonElementText).toBe('Dewiswch ffeil')
+ expect(statusElementText).toBe("Dim ffeil wedi'i dewis")
+ })
+
+ describe('status element', () => {
+ it('uses the correct translation when no files are selected', async () => {
+ const statusText = await page.$eval(statusSelector, (el) =>
+ el.innerHTML.trim()
+ )
+
+ expect(statusText).toBe("Dim ffeil wedi'i dewis")
+ })
+
+ it('uses the correct translation when multiple files are selected', async () => {
+ const [fileChooser] = await Promise.all([
+ page.waitForFileChooser(),
+ page.click(buttonSelector)
+ ])
+ await fileChooser.accept(['testfile1.txt', 'testfile2.pdf'])
+
+ const statusText = await page.$eval(statusSelector, (el) =>
+ el.innerHTML.trim()
+ )
+
+ expect(statusText).toBe("2 ffeil wedi'u dewis")
+ })
+ })
+ })
+
+ describe('disabled state syncing', () => {
+ it('disables the button if the input is disabled on page load', async () => {
+ await render(page, 'file-upload', examples.enhanced, {
+ beforeInitialisation() {
+ document
+ .querySelector('[type="file"]')
+ .setAttribute('disabled', '')
+ }
+ })
+
+ const buttonDisabled = await page.$eval(buttonSelector, (el) =>
+ el.hasAttribute('disabled')
+ )
+ const dropZoneDisabled = await page.$eval(wrapperSelector, (el) =>
+ el.classList.contains('govuk-drop-zone--disabled')
+ )
+
+ expect(buttonDisabled).toBeTruthy()
+ expect(dropZoneDisabled).toBeTruthy()
+ })
+
+ it('disables the button if the input is disabled programmatically', async () => {
+ await render(page, 'file-upload', examples.enhanced)
+
+ await page.$eval(inputSelector, (el) =>
+ el.setAttribute('disabled', '')
+ )
+
+ const buttonDisabledAfter = await page.$eval(buttonSelector, (el) =>
+ el.hasAttribute('disabled')
+ )
+ const dropZoneDisabled = await page.$eval(wrapperSelector, (el) =>
+ el.classList.contains('govuk-drop-zone--disabled')
+ )
+
+ expect(buttonDisabledAfter).toBeTruthy()
+ expect(dropZoneDisabled).toBeTruthy()
+ })
+
+ it('enables the button if the input is enabled programmatically', async () => {
+ await render(page, 'file-upload', examples.enhanced, {
+ beforeInitialisation() {
+ document
+ .querySelector('[type="file"]')
+ .setAttribute('disabled', '')
+ }
+ })
+
+ await page.$eval(inputSelector, (el) =>
+ el.removeAttribute('disabled')
+ )
+
+ const buttonDisabled = await page.$eval(buttonSelector, (el) =>
+ el.hasAttribute('disabled')
+ )
+ const dropZoneDisabled = await page.$eval(wrapperSelector, (el) =>
+ el.classList.contains('govuk-drop-zone--disabled')
+ )
+
+ expect(buttonDisabled).toBeFalsy()
+ expect(dropZoneDisabled).toBeFalsy()
+ })
+ })
+
+ describe('aria-describedby', () => {
+ it('copies the `aria-describedby` attribute from the ` ` to the ``', async () => {
+ await render(
+ page,
+ 'file-upload',
+ examples['enhanced, with error message and hint']
+ )
+
+ const $button = await page.$(buttonSelector)
+ const ariaDescribedBy = await $button.evaluate((el) =>
+ el.getAttribute('aria-describedby')
+ )
+
+ expect(ariaDescribedBy).toBe('file-upload-3-hint file-upload-3-error')
+ })
+
+ it('does not add an `aria-describedby` attribute to the `` if there is none on the ` `', async () => {
+ await render(page, 'file-upload', examples.enhanced)
+
+ const $button = await page.$(buttonSelector)
+ const ariaDescribedBy = await $button.evaluate((el) =>
+ el.getAttribute('aria-describedby')
+ )
+
+ expect(ariaDescribedBy).toBeNull()
+ })
+ })
+
+ describe('errors at instantiation', () => {
+ let examples
+
+ beforeAll(async () => {
+ examples = await getExamples('file-upload')
+ })
+
+ it('can throw a SupportError if appropriate', async () => {
+ await expect(
+ render(page, 'file-upload', examples.enhanced, {
+ beforeInitialisation() {
+ document.body.classList.remove('govuk-frontend-supported')
+ }
+ })
+ ).rejects.toMatchObject({
+ cause: {
+ name: 'SupportError',
+ message:
+ 'GOV.UK Frontend initialised without `` from template `