From 3aef4d4bc34d5fb011cf3047a3e31e12ebe2c35e Mon Sep 17 00:00:00 2001 From: beeps Date: Thu, 12 Sep 2024 14:53:35 +0100 Subject: [PATCH 01/77] [WIP] Spike progressively enhanced file upload --- packages/govuk-frontend/src/govuk/all.mjs | 1 + .../src/govuk/all.puppeteer.test.js | 1 + .../govuk/components/file-upload/_index.scss | 48 ++++ .../components/file-upload/file-upload.mjs | 228 ++++++++++++++++++ .../components/file-upload/file-upload.yaml | 40 ++- .../govuk/components/file-upload/template.njk | 2 +- .../src/govuk/init.jsdom.test.mjs | 2 + packages/govuk-frontend/src/govuk/init.mjs | 4 + .../tasks/build/package.unit.test.mjs | 1 + 9 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs diff --git a/packages/govuk-frontend/src/govuk/all.mjs b/packages/govuk-frontend/src/govuk/all.mjs index c82692ff7b..ea1309b790 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..4b20003587 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss +++ b/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss @@ -46,4 +46,52 @@ cursor: not-allowed; } } + + .govuk-file-upload-wrapper { + display: inline-flex; + align-items: baseline; + position: relative; + } + + .govuk-file-upload-wrapper--show-dropzone { + $dropzone-padding: govuk-spacing(2); + + margin-top: -$dropzone-padding; + margin-left: -$dropzone-padding; + padding: $dropzone-padding; + outline: 2px dotted govuk-colour("mid-grey"); + background-color: govuk-colour("light-grey"); + + .govuk-file-upload__button, + .govuk-file-upload__status { + // When the dropzone is hovered over, make these aspects not accept + // mouse events, so dropped files fall through to the input beneath them + pointer-events: none; + } + } + + .govuk-file-upload-wrapper .govuk-file-upload { + // Make the native control take up the entire space of the element, but + // invisible and behind the other elements until we need it + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + opacity: 0; + } + + .govuk-file-upload__button { + width: auto; + margin-bottom: 0; + flex-grow: 0; + flex-shrink: 0; + } + + .govuk-file-upload__status { + margin-bottom: 0; + margin-left: govuk-spacing(2); + } } 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..fd5ea46443 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -0,0 +1,228 @@ +import { closestAttributeValue } from '../../common/closest-attribute-value.mjs' +import { mergeConfigs, normaliseDataset } from '../../common/configuration.mjs' +import { ElementError } from '../../errors/index.mjs' +import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs' +import { I18n } from '../../i18n.mjs' + +/** + * File upload component + * + * @preserve + */ +export class FileUpload extends GOVUKFrontendComponent { + /** + * @private + * @type {HTMLInputElement} + */ + $input + + /** + * @private + * @type {HTMLElement} + */ + $wrapper + + /** + * @private + * @type {HTMLButtonElement} + */ + $button + + /** + * @private + * @type {HTMLElement} + */ + $status + + /** + * @private + * @type {FileUploadConfig} + */ + config + + /** @private */ + i18n + + /** + * @param {Element | null} $input - File input element + * @param {FileUploadConfig} [config] - File Upload config + */ + constructor($input, config = {}) { + super($input) + + if (!($input instanceof HTMLInputElement)) { + throw new ElementError({ + component: FileUpload, + element: $input, + expectedType: 'HTMLInputElement', + identifier: 'Root element (`$module`)' + }) + } + + if ($input.type !== 'file') { + throw new ElementError('File upload: Form field must be of type `file`.') + } + + this.config = mergeConfigs( + FileUpload.defaults, + config, + normaliseDataset(FileUpload, $input.dataset) + ) + + this.i18n = new I18n(this.config.i18n, { + // Read the fallback if necessary rather than have it set in the defaults + locale: closestAttributeValue($input, 'lang') + }) + + $input.addEventListener('change', this.onChange.bind(this)) + this.$input = $input + + // Wrapping element. This defines the boundaries of our drag and drop area. + const $wrapper = document.createElement('div') + $wrapper.className = 'govuk-file-upload-wrapper' + $wrapper.addEventListener('dragover', this.onDragOver.bind(this)) + $wrapper.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this)) + $wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this)) + + // Create the file selection button + const $button = document.createElement('button') + $button.className = + 'govuk-button govuk-button--secondary govuk-file-upload__button' + $button.type = 'button' + $button.innerText = this.i18n.t('selectFilesButton') + $button.addEventListener('click', this.onClick.bind(this)) + + // Create status element that shows what/how many files are selected + const $status = document.createElement('span') + $status.className = 'govuk-body govuk-file-upload__status' + $status.innerText = this.i18n.t('filesSelectedDefault') + $status.setAttribute('role', 'status') + + // Assemble these all together + $wrapper.insertAdjacentElement('beforeend', $button) + $wrapper.insertAdjacentElement('beforeend', $status) + + // Inject all this *after* the native file input + this.$input.insertAdjacentElement('afterend', $wrapper) + + // Move the native file input to inside of the wrapper + $wrapper.insertAdjacentElement('afterbegin', this.$input) + + // Make all these new variables available to the module + this.$wrapper = $wrapper + this.$button = $button + this.$status = $status + + // Bind change event to the underlying input + this.$input.addEventListener('change', this.onChange.bind(this)) + } + + /** + * Check if the value of the underlying input has changed + */ + onChange() { + if (!this.$input.files) { + return + } + + 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('filesSelectedDefault') + } 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('filesSelected', { + count: fileCount + }) + } + } + + /** + * When the button is clicked, emulate clicking the actual, hidden file input + */ + onClick() { + this.$input.click() + } + + /** + * When a file is dragged over the container, show a visual indicator that a + * file can be dropped here. + */ + onDragOver() { + this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone') + } + + /** + * When a dragged file leaves the container, or the file is dropped, + * remove the visual indicator. + */ + onDragLeaveOrDrop() { + this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone') + } + + /** + * 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: { + selectFilesButton: 'Choose file', + filesSelectedDefault: 'No file chosen', + filesSelected: { + one: '%{count} file chosen', + other: '%{count} files chosen' + } + } + }) + + /** + * File upload config schema + * + * @constant + * @satisfies {Schema} + */ + static schema = Object.freeze({ + properties: { + i18n: { type: 'object' } + } + }) +} + +/** + * File upload config + * + * @see {@link FileUpload.defaults} + * @typedef {object} FileUploadConfig + * @property {FileUploadTranslations} [i18n=FileUpload.defaults.i18n] - File upload translations + */ + +/** + * File upload translations + * + * @see {@link FileUpload.defaults.i18n} + * @typedef {object} FileUploadTranslations + * + * Messages used by the component + * @property {string} [selectFiles] - Text of button that opens file browser + * @property {TranslationPluralForms} [filesSelected] - Text indicating how + * many files have been selected + */ + +/** + * @import { Schema } from '../../common/configuration.mjs' + * @import { TranslationPluralForms } from '../../i18n.mjs' + */ diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml index 0ce412ab04..d5ecd5795d 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml @@ -88,6 +88,31 @@ examples: name: file-upload-1 label: text: Upload a file + - name: allows multiple files + options: + id: file-upload-1 + name: file-upload-1 + label: + text: Upload a file + attributes: + multiple: true + - name: allows image files only + options: + id: file-upload-1 + name: file-upload-1 + label: + text: Upload a file + attributes: + accept: 'image/*' + - name: allows direct media capture + description: Currently only works on mobile devices. + options: + id: file-upload-1 + name: file-upload-1 + label: + text: Upload a file + attributes: + capture: 'user' - name: with hint text options: id: file-upload-2 @@ -106,13 +131,6 @@ examples: 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 - - name: with value - options: - id: file-upload-4 - name: file-upload-4 - value: C:\fakepath\myphoto.jpg - label: - text: Upload a photo - name: with label as page heading options: id: file-upload-1 @@ -131,6 +149,14 @@ examples: classes: extra-class # Hidden examples are not shown in the review app, but are used for tests and HTML fixtures + - name: with value + hidden: true + options: + id: file-upload-4 + name: file-upload-4 + value: C:\fakepath\myphoto.jpg + label: + text: Upload a photo - name: attributes hidden: true options: diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk index d2386de84d..21d69c5770 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk @@ -44,7 +44,7 @@ {% if params.formGroup.beforeInput %} {{ params.formGroup.beforeInput.html | safe | trim | indent(2) if params.formGroup.beforeInput.html else params.formGroup.beforeInput.text }} {% endif %} - { 'character-count', 'error-summary', 'exit-this-page', + 'file-upload', 'notification-banner', 'password-input' ] diff --git a/packages/govuk-frontend/src/govuk/init.mjs b/packages/govuk-frontend/src/govuk/init.mjs index 0e4969fca3..a5a4be9533 100644 --- a/packages/govuk-frontend/src/govuk/init.mjs +++ b/packages/govuk-frontend/src/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' @@ -44,6 +45,7 @@ function initAll(config) { [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], + [FileUpload, config.fileUpload], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], @@ -176,6 +178,7 @@ export { initAll, createAll } * @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 */ @@ -190,6 +193,7 @@ export { initAll, createAll } * @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' */ /** diff --git a/packages/govuk-frontend/tasks/build/package.unit.test.mjs b/packages/govuk-frontend/tasks/build/package.unit.test.mjs index e8e048356f..61e35d8006 100644 --- a/packages/govuk-frontend/tasks/build/package.unit.test.mjs +++ b/packages/govuk-frontend/tasks/build/package.unit.test.mjs @@ -196,6 +196,7 @@ describe('packages/govuk-frontend/dist/', () => { 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'; From 7a7efe4a357e07ce2b243ad0e17982ed86ddef50 Mon Sep 17 00:00:00 2001 From: Owen Jones Date: Fri, 4 Oct 2024 15:40:49 +0100 Subject: [PATCH 02/77] click label instead of input --- .../govuk/components/file-upload/file-upload.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 index fd5ea46443..cc35806909 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -74,6 +74,15 @@ export class FileUpload extends GOVUKFrontendComponent { locale: closestAttributeValue($input, 'lang') }) + this.$label = document.querySelector(`[for="${$input.id}"]`) + + if (!this.$label) { + throw new ElementError({ + componentName: 'File upload', + identifier: 'No label' + }) + } + $input.addEventListener('change', this.onChange.bind(this)) this.$input = $input @@ -147,7 +156,9 @@ export class FileUpload extends GOVUKFrontendComponent { * When the button is clicked, emulate clicking the actual, hidden file input */ onClick() { - this.$input.click() + if (this.$label instanceof HTMLElement) { + this.$label.click() + } } /** From b26716fc873b939c3e9c5a37c2edf78b73878d3d Mon Sep 17 00:00:00 2001 From: Owen Jones Date: Wed, 16 Oct 2024 17:56:23 +0100 Subject: [PATCH 03/77] Add aria-hidden and tabindex -1 to input This change is mutated by a lot of mess from me trying to make typescript not upset with me --- .../components/file-upload/file-upload.mjs | 85 +++++++++++-------- 1 file changed, 48 insertions(+), 37 deletions(-) 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 index cc35806909..31ed1e29d8 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -12,25 +12,16 @@ import { I18n } from '../../i18n.mjs' export class FileUpload extends GOVUKFrontendComponent { /** * @private - * @type {HTMLInputElement} - */ - $input - - /** - * @private - * @type {HTMLElement} */ $wrapper /** * @private - * @type {HTMLButtonElement} */ $button /** * @private - * @type {HTMLElement} */ $status @@ -38,60 +29,53 @@ export class FileUpload extends GOVUKFrontendComponent { * @private * @type {FileUploadConfig} */ + // eslint-disable-next-line + // @ts-ignore config /** @private */ i18n /** - * @param {Element | null} $input - File input element + * @param {Element | null} $root - File input element * @param {FileUploadConfig} [config] - File Upload config */ - constructor($input, config = {}) { - super($input) + constructor($root, config = {}) { + super($root) - if (!($input instanceof HTMLInputElement)) { - throw new ElementError({ - component: FileUpload, - element: $input, - expectedType: 'HTMLInputElement', - identifier: 'Root element (`$module`)' - }) + if (!(this.$root instanceof HTMLInputElement)) { + return } - if ($input.type !== 'file') { - throw new ElementError('File upload: Form field must be of type `file`.') + if (this.$root.type !== 'file') { + throw new ElementError( + 'File upload: Form field must be an input of type `file`.' + ) } this.config = mergeConfigs( FileUpload.defaults, config, - normaliseDataset(FileUpload, $input.dataset) + normaliseDataset(FileUpload, this.$root.dataset) ) this.i18n = new I18n(this.config.i18n, { // Read the fallback if necessary rather than have it set in the defaults - locale: closestAttributeValue($input, 'lang') + locale: closestAttributeValue(this.$root, 'lang') }) - this.$label = document.querySelector(`[for="${$input.id}"]`) + this.$label = document.querySelector(`[for="${this.$root.id}"]`) if (!this.$label) { throw new ElementError({ - componentName: 'File upload', + component: FileUpload, identifier: 'No label' }) } - $input.addEventListener('change', this.onChange.bind(this)) - this.$input = $input - // Wrapping element. This defines the boundaries of our drag and drop area. const $wrapper = document.createElement('div') $wrapper.className = 'govuk-file-upload-wrapper' - $wrapper.addEventListener('dragover', this.onDragOver.bind(this)) - $wrapper.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this)) - $wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this)) // Create the file selection button const $button = document.createElement('button') @@ -112,29 +96,50 @@ export class FileUpload extends GOVUKFrontendComponent { $wrapper.insertAdjacentElement('beforeend', $status) // Inject all this *after* the native file input - this.$input.insertAdjacentElement('afterend', $wrapper) + this.$root.insertAdjacentElement('afterend', $wrapper) // Move the native file input to inside of the wrapper - $wrapper.insertAdjacentElement('afterbegin', this.$input) + $wrapper.insertAdjacentElement('afterbegin', this.$root) // Make all these new variables available to the module this.$wrapper = $wrapper this.$button = $button this.$status = $status + // with everything set up, apply attributes to programmatically hide the input + this.$root.setAttribute('aria-hidden', 'true') + this.$root.setAttribute('tabindex', '-1') + // Bind change event to the underlying input - this.$input.addEventListener('change', this.onChange.bind(this)) + this.$root.addEventListener('change', this.onChange.bind(this)) + this.$wrapper.addEventListener('dragover', this.onDragOver.bind(this)) + this.$wrapper.addEventListener( + 'dragleave', + this.onDragLeaveOrDrop.bind(this) + ) + this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this)) } /** * Check if the value of the underlying input has changed */ onChange() { - if (!this.$input.files) { + if (!('files' in this.$root)) { return } - const fileCount = this.$input.files.length + if (!this.$root.files) { + return + } + + // eslint-disable-next-line + // @ts-ignore + const fileCount = this.$root.files.length // eslint-disable-line + + // trying to appease typescript + if (!this.$status || !this.i18n) { + return + } if (fileCount === 0) { // If there are no files, show the default selection text @@ -143,7 +148,9 @@ export class FileUpload extends GOVUKFrontendComponent { // If there is 1 file, just show the file name fileCount === 1 ) { - this.$status.innerText = this.$input.files[0].name + // eslint-disable-next-line + // @ts-ignore + this.$status.innerText = this.$root.files[0].name // eslint-disable-line } else { // Otherwise, tell the user how many files are selected this.$status.innerText = this.i18n.t('filesSelected', { @@ -166,6 +173,8 @@ export class FileUpload extends GOVUKFrontendComponent { * file can be dropped here. */ onDragOver() { + // eslint-disable-next-line + // @ts-ignore this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone') } @@ -174,6 +183,8 @@ export class FileUpload extends GOVUKFrontendComponent { * remove the visual indicator. */ onDragLeaveOrDrop() { + // eslint-disable-next-line + // @ts-ignore this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone') } From 28898992ceb4bca60cae42c39e06955d5c121e9f Mon Sep 17 00:00:00 2001 From: beeps Date: Fri, 18 Oct 2024 14:25:59 +0100 Subject: [PATCH 04/77] Add `multiple` Nunjucks parameter Setting this via the `attributes` parameter raises complaints from the test that lints our HTML output. Should quieten that failing test. --- .../src/govuk/components/file-upload/file-upload.yaml | 7 +++++-- .../src/govuk/components/file-upload/template.njk | 1 + .../src/govuk/components/file-upload/template.test.js | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml index d5ecd5795d..b9fab9727b 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml @@ -15,6 +15,10 @@ params: type: boolean 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 required: false @@ -94,8 +98,7 @@ examples: name: file-upload-1 label: text: Upload a file - attributes: - multiple: true + multiple: true - name: allows image files only options: id: file-upload-1 diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk index 21d69c5770..62c9bf3175 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk @@ -47,6 +47,7 @@ {% if params.formGroup.afterInput %} diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js b/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js index 7fad86a7c4..e2dd676c2b 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js @@ -67,6 +67,13 @@ describe('File upload', () => { expect($component.attr('aria-describedby')).toMatch('test-target-element') }) + it('renders with multiple', () => { + const $ = render('file-upload', examples['allows multiple files']) + + const $component = $('.govuk-file-upload') + expect($component.attr('multiple')).toBeTruthy() + }) + it('renders with attributes', () => { const $ = render('file-upload', examples.attributes) From b48efebfb99ed6baab7aec13b45c0db63f1730ae Mon Sep 17 00:00:00 2001 From: beeps Date: Fri, 18 Oct 2024 16:30:53 +0100 Subject: [PATCH 05/77] Tweaks to dropzone styles --- .../src/govuk/components/file-upload/_index.scss | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 4b20003587..309ea05fd5 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss +++ b/packages/govuk-frontend/src/govuk/components/file-upload/_index.scss @@ -55,12 +55,14 @@ .govuk-file-upload-wrapper--show-dropzone { $dropzone-padding: govuk-spacing(2); + $dropzone-offset: $dropzone-padding + $govuk-border-width-form-element; - margin-top: -$dropzone-padding; - margin-left: -$dropzone-padding; + // Add negative margins to all sides so that content doesn't jump due to + // the addition of the padding and border. + margin: -$dropzone-offset; padding: $dropzone-padding; - outline: 2px dotted govuk-colour("mid-grey"); - background-color: govuk-colour("light-grey"); + border: $govuk-border-width-form-element dashed $govuk-input-border-colour; + background-color: $govuk-body-background-colour; .govuk-file-upload__button, .govuk-file-upload__status { From 3488c8942ba63102975f73dd515a5f449e05292d Mon Sep 17 00:00:00 2001 From: beeps Date: Mon, 21 Oct 2024 09:33:08 +0100 Subject: [PATCH 06/77] Remove setting aria-hidden Chromium actively ignores this attribute on file inputs and throws a warning in the console --- .../src/govuk/components/file-upload/file-upload.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index 31ed1e29d8..a0dc121301 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -106,8 +106,7 @@ export class FileUpload extends GOVUKFrontendComponent { this.$button = $button this.$status = $status - // with everything set up, apply attributes to programmatically hide the input - this.$root.setAttribute('aria-hidden', 'true') + // Prevent the hidden input being tabbed to by keyboard users this.$root.setAttribute('tabindex', '-1') // Bind change event to the underlying input From a7aff310aee97f87d037d44fad7b7ba6aa1d6904 Mon Sep 17 00:00:00 2001 From: beeps Date: Mon, 21 Oct 2024 10:56:49 +0100 Subject: [PATCH 07/77] Syncronise disabled state between input and button Adds a mutation observer that looks to see if the `disabled` attribute of the input changes and, if so, updates the button to match --- .../components/file-upload/file-upload.mjs | 39 +++++++++++++++++++ .../components/file-upload/file-upload.yaml | 7 ++++ .../components/file-upload/template.test.js | 7 ++++ 3 files changed, 53 insertions(+) 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 index a0dc121301..00b22bcd6f 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -109,6 +109,10 @@ export class FileUpload extends GOVUKFrontendComponent { // Prevent the hidden input being tabbed to by keyboard users this.$root.setAttribute('tabindex', '-1') + // Syncronise the `disabled` state between the button and underlying input + this.updateDisabledState() + this.observeDisabledState() + // Bind change event to the underlying input this.$root.addEventListener('change', this.onChange.bind(this)) this.$wrapper.addEventListener('dragover', this.onDragOver.bind(this)) @@ -187,6 +191,41 @@ export class FileUpload extends GOVUKFrontendComponent { this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone') } + /** + * Create a mutation observer to check if the input's attributes altered. + */ + observeDisabledState() { + const observer = new MutationObserver((mutationList) => { + for (const mutation of mutationList) { + console.log('mutation', mutation) + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'disabled' + ) { + this.updateDisabledState() + } + } + }) + + observer.observe(this.$root, { + attributes: true + }) + } + + /** + * Synchronise the `disabled` state between the input and replacement button. + */ + updateDisabledState() { + if ( + !(this.$root instanceof HTMLInputElement) || + !(this.$button instanceof HTMLButtonElement) + ) { + return + } + + this.$button.disabled = this.$root.disabled + } + /** * Name for the component used when initialising using data-module attributes. */ diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml index b9fab9727b..1e5e4c8f83 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml @@ -116,6 +116,13 @@ examples: text: Upload a file attributes: capture: 'user' + - name: is disabled + options: + id: file-upload-1 + name: file-upload-1 + label: + text: Upload a file + disabled: true - name: with hint text options: id: file-upload-2 diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js b/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js index e2dd676c2b..91da31ac8a 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js @@ -74,6 +74,13 @@ describe('File upload', () => { expect($component.attr('multiple')).toBeTruthy() }) + it('renders with multiple', () => { + const $ = render('file-upload', examples['is disabled']) + + const $component = $('.govuk-file-upload') + expect($component.attr('disabled')).toBeTruthy() + }) + it('renders with attributes', () => { const $ = render('file-upload', examples.attributes) From c006982914802abd2193e1e4f0cee5a6b367d994 Mon Sep 17 00:00:00 2001 From: beeps Date: Mon, 21 Oct 2024 18:19:31 +0100 Subject: [PATCH 08/77] Add i18n parameters --- .../govuk/components/file-upload/file-upload.yaml | 11 +++++++++++ .../src/govuk/components/file-upload/template.njk | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml index 1e5e4c8f83..6f285a4cc1 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml @@ -157,6 +157,17 @@ examples: text: Upload a file formGroup: classes: extra-class + - name: translated + options: + id: file-upload-1 + name: file-upload-1 + label: + text: Llwythwch ffeil i fyny + selectFilesButtonText: Dewiswch ffeil + filesSelectedDefaultText: Dim ffeiliau wedi'u dewis + filesSelectedText: + one: "%{count} ffeil wedi'i dewis" + other: "%{count} ffeil wedi'u dewis" # Hidden examples are not shown in the review app, but are used for tests and HTML fixtures - name: with value diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk index 62c9bf3175..804c3b9c3f 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk @@ -1,4 +1,5 @@ {% from "../../macros/attributes.njk" import govukAttributes %} +{% from "../../macros/i18n.njk" import govukI18nAttributes %} {% from "../error-message/macro.njk" import govukErrorMessage %} {% from "../hint/macro.njk" import govukHint %} {% from "../label/macro.njk" import govukLabel %} @@ -49,6 +50,18 @@ {%- if params.disabled %} disabled{% endif %} {%- if params.multiple %} multiple{% endif %} {%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %} + {{- govukI18nAttributes({ + key: 'select-files-button', + message: params.selectFilesButtonText + }) -}} + {{- govukI18nAttributes({ + key: 'files-selected-default', + message: params.filesSelectedDefaultText + }) -}} + {{- govukI18nAttributes({ + key: 'files-selected', + message: params.filesSelectedText + }) -}} {{- govukAttributes(params.attributes) }}> {% if params.formGroup.afterInput %} {{ params.formGroup.afterInput.html | safe | trim | indent(2) if params.formGroup.afterInput.html else params.formGroup.afterInput.text }} From 07c4647bf04e9db68edd1575593cfc3c2ad918c7 Mon Sep 17 00:00:00 2001 From: beeps Date: Tue, 22 Oct 2024 10:24:06 +0100 Subject: [PATCH 09/77] Write JavaScript functionality tests --- .../file-upload/file-upload.puppeteer.test.js | 286 ++++++++++++++++++ .../components/file-upload/file-upload.yaml | 3 +- 2 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js 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..cf502b4879 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.puppeteer.test.js @@ -0,0 +1,286 @@ +const { render } = require('@govuk-frontend/helpers/puppeteer') +const { getExamples } = require('@govuk-frontend/lib/components') + +const inputSelector = '.govuk-file-upload' +const wrapperSelector = '.govuk-file-upload-wrapper' +const buttonSelector = '.govuk-file-upload__button' +const statusSelector = '.govuk-file-upload__status' + +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.default) + }) + + 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('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, (el) => + el.innerHTML.trim() + ) + + expect(buttonElementText).toBe('Choose file') + }) + }) + + describe('status element', () => { + it('renders the status element', async () => { + const statusElement = await page.$eval(statusSelector, (el) => el) + + expect(statusElement).toBeDefined() + }) + + it('renders the status element with role', async () => { + const statusElementRole = await page.$eval(statusSelector, (el) => + el.getAttribute('role') + ) + + expect(statusElementRole).toBe('status') + }) + + it('renders the status element with default text', async () => { + const statusElementText = await page.$eval(statusSelector, (el) => + el.innerHTML.trim() + ) + + expect(statusElementText).toBe('No file chosen') + }) + }) + }) + + describe('when clicking the choose file button', () => { + it('opens the file picker', async () => { + // 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.default) + + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.click(buttonSelector) + ]) + + 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.default) + + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.click(inputSelector) + ]) + 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['allows multiple files']) + + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.click(inputSelector) + ]) + 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('i18n', () => { + beforeEach(async () => { + await render(page, 'file-upload', examples.translated) + }) + + it('uses the correct translation for the choose file button', async () => { + const buttonText = await page.$eval(buttonSelector, (el) => + el.innerHTML.trim() + ) + + expect(buttonText).toBe('Dewiswch ffeil') + }) + + 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 ffeiliau wedi'u dewis") + }) + + it('uses the correct translation when multiple files are selected', async () => { + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.click(inputSelector) + ]) + 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") + }) + }) + }) + }) + }) +}) diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml index 6f285a4cc1..3aee3741fa 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml @@ -163,11 +163,12 @@ examples: name: file-upload-1 label: text: Llwythwch ffeil i fyny + multiple: true selectFilesButtonText: Dewiswch ffeil filesSelectedDefaultText: Dim ffeiliau wedi'u dewis filesSelectedText: - one: "%{count} ffeil wedi'i dewis" other: "%{count} ffeil wedi'u dewis" + one: "%{count} ffeil wedi'i dewis" # Hidden examples are not shown in the review app, but are used for tests and HTML fixtures - name: with value From a7bd798c3b36d746ace0cc90f4b3bce001ab6e47 Mon Sep 17 00:00:00 2001 From: beeps Date: Tue, 22 Oct 2024 10:24:17 +0100 Subject: [PATCH 10/77] Fix component not rendering plural objects correctly --- .../src/govuk/components/file-upload/template.njk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk index 804c3b9c3f..38a5c5f9c3 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk @@ -60,7 +60,7 @@ }) -}} {{- govukI18nAttributes({ key: 'files-selected', - message: params.filesSelectedText + messages: params.filesSelectedText }) -}} {{- govukAttributes(params.attributes) }}> {% if params.formGroup.afterInput %} From b765770212ad5a764274c7441d5e4462e9fa56d0 Mon Sep 17 00:00:00 2001 From: beeps Date: Tue, 22 Oct 2024 17:02:04 +0100 Subject: [PATCH 11/77] Add Nunjucks parameter documentation --- .../src/govuk/components/file-upload/file-upload.mjs | 2 ++ .../govuk/components/file-upload/file-upload.yaml | 12 ++++++++++++ 2 files changed, 14 insertions(+) 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 index 00b22bcd6f..5bba605c2c 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -243,6 +243,8 @@ export class FileUpload extends GOVUKFrontendComponent { selectFilesButton: 'Choose file', filesSelectedDefault: 'No file chosen', filesSelected: { + // 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' } diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml index 3aee3741fa..7acf24b4ee 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml @@ -77,6 +77,18 @@ params: type: string required: true description: HTML to add after the input. If `html` is provided, the `text` option will be ignored. + - name: selectFilesButtonText + type: string + required: false + description: The text of the button that opens the file picker. JavaScript enhanced version of the component only. Default is "Choose file". + - name: filesSelected + type: object + required: false + description: The text to display when multiple files has been chosen by the user. JavaScript enhanced version of the component only. The component will replace the `%{count}` placeholder with the number of files selected. This is a [pluralised list of messages](https://frontend.design-system.service.gov.uk/localise-govuk-frontend). + - name: filesSelectedDefault + type: string + required: false + description: The text to display when no file has been chosen by the user. JavaScript enhanced version of the component only. Default is "No file chosen". - name: classes type: string required: false From 95d9cec11789b94da3ed99b0466d109391e39b65 Mon Sep 17 00:00:00 2001 From: beeps Date: Tue, 22 Oct 2024 17:42:21 +0100 Subject: [PATCH 12/77] Add tests for disable state syncronisation --- .../file-upload/file-upload.puppeteer.test.js | 45 +++++++++++++++++++ .../components/file-upload/file-upload.yaml | 2 +- .../components/file-upload/template.test.js | 4 +- 3 files changed, 48 insertions(+), 3 deletions(-) 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 index cf502b4879..a0172ae79d 100644 --- 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 @@ -281,6 +281,51 @@ describe('/components/file-upload', () => { }) }) }) + + describe('disabled state syncing', () => { + it('disables the button if the input is disabled on page load', async () => { + await render(page, 'file-upload', examples.disabled) + + const buttonDisabled = await page.$eval(buttonSelector, (el) => + el.hasAttribute('disabled') + ) + + expect(buttonDisabled).toBeTruthy() + }) + + it('disables the button if the input is disabled programatically', async () => { + await render(page, 'file-upload', examples.default) + + await page.$eval(inputSelector, (el) => + el.setAttribute('disabled', '') + ) + + const buttonDisabledAfter = await page.$eval(buttonSelector, (el) => + el.hasAttribute('disabled') + ) + + expect(buttonDisabledAfter).toBeTruthy() + }) + + it('enables the button if the input is enabled programatically', async () => { + await render(page, 'file-upload', examples.disabled) + + const buttonDisabledBefore = await page.$eval(buttonSelector, (el) => + el.hasAttribute('disabled') + ) + + await page.$eval(inputSelector, (el) => + el.removeAttribute('disabled') + ) + + const buttonDisabledAfter = await page.$eval(buttonSelector, (el) => + el.hasAttribute('disabled') + ) + + expect(buttonDisabledBefore).toBeTruthy() + expect(buttonDisabledAfter).toBeFalsy() + }) + }) }) }) }) diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml index 7acf24b4ee..9637ea648b 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.yaml @@ -128,7 +128,7 @@ examples: text: Upload a file attributes: capture: 'user' - - name: is disabled + - name: disabled options: id: file-upload-1 name: file-upload-1 diff --git a/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js b/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js index 91da31ac8a..f7b3d30271 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.test.js @@ -74,8 +74,8 @@ describe('File upload', () => { expect($component.attr('multiple')).toBeTruthy() }) - it('renders with multiple', () => { - const $ = render('file-upload', examples['is disabled']) + it('renders with disabled', () => { + const $ = render('file-upload', examples.disabled) const $component = $('.govuk-file-upload') expect($component.attr('disabled')).toBeTruthy() From 144486b0d9e1626e2388a347758892af57094a5e Mon Sep 17 00:00:00 2001 From: beeps Date: Thu, 24 Oct 2024 09:44:21 +0100 Subject: [PATCH 13/77] Show dropzone when dragover page and not input --- .../src/govuk/components/file-upload/file-upload.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 index 5bba605c2c..83434fe20d 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -115,12 +115,13 @@ export class FileUpload extends GOVUKFrontendComponent { // Bind change event to the underlying input this.$root.addEventListener('change', this.onChange.bind(this)) - this.$wrapper.addEventListener('dragover', this.onDragOver.bind(this)) - this.$wrapper.addEventListener( - 'dragleave', - this.onDragLeaveOrDrop.bind(this) - ) + + // When a file is dropped on the input this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this)) + + // When a file is dragged over the page (or dragged off the page) + document.addEventListener('dragover', this.onDragOver.bind(this)) + document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this)) } /** From 1e9125b17578b01b3fb43c4c5e448be5423a1171 Mon Sep 17 00:00:00 2001 From: beeps Date: Fri, 25 Oct 2024 13:19:26 +0100 Subject: [PATCH 14/77] Change dragover to dragenter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragenter is fired only once when the drag first touches the page, whereas dragover fires continuously. As we’re only toggling the visbility of the dropzone, dragenter will suffice for our needs. --- .../src/govuk/components/file-upload/file-upload.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index 83434fe20d..9b4d74fb31 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -120,7 +120,7 @@ export class FileUpload extends GOVUKFrontendComponent { this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this)) // When a file is dragged over the page (or dragged off the page) - document.addEventListener('dragover', this.onDragOver.bind(this)) + document.addEventListener('dragenter', this.onDragEnter.bind(this)) document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this)) } @@ -175,8 +175,14 @@ export class FileUpload extends GOVUKFrontendComponent { /** * When a file is dragged over the container, show a visual indicator that a * file can be dropped here. + * + * @param {DragEvent} event - the drag event */ - onDragOver() { + onDragEnter(event) { + // Check if the thing being dragged is a file (and not text or something + // else), we only want to indicate files. + console.log(event) + // eslint-disable-next-line // @ts-ignore this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone') From 8c27bd0c6e42e3f1b8d3546c55b2128ac7b2a224 Mon Sep 17 00:00:00 2001 From: Romaric Pascal Date: Thu, 9 Jan 2025 15:16:15 +0000 Subject: [PATCH 15/77] Add tests for initialisation errors specific to the component Ahead of using `ConfigurableComponent` add tests for errors that won't be handled by the base class, so we know the functionality is still there. Tests for the errors thrown by `ConfiguableComponent` will be added when the class gets used. --- .../file-upload/file-upload.puppeteer.test.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 index a0172ae79d..1975edb351 100644 --- 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 @@ -326,6 +326,49 @@ describe('/components/file-upload', () => { expect(buttonDisabledAfter).toBeFalsy() }) }) + + describe('errors at instantiation', () => { + let examples + + beforeAll(async () => { + examples = await getExamples('file-upload') + }) + + describe('missing or misconfigured elements', () => { + it('throws if the input type is not "file"', async () => { + await expect( + render(page, 'file-upload', examples.default, { + beforeInitialisation() { + document + .querySelector('[type="file"]') + .setAttribute('type', 'text') + } + }) + ).rejects.toMatchObject({ + cause: { + name: 'ElementError', + message: + 'File upload: Form field must be an input of type `file`.' + } + }) + }) + + it('throws if no label is present', async () => { + await expect( + render(page, 'file-upload', examples.default, { + beforeInitialisation() { + document.querySelector('label').remove() + } + }) + ).rejects.toMatchObject({ + cause: { + name: 'ElementError', + message: 'govuk-file-upload: No label not found' + } + }) + }) + }) + }) }) }) }) From f16d254ad03bb74a95fb7cb7ed5140fa38ad4beb Mon Sep 17 00:00:00 2001 From: Romaric Pascal Date: Thu, 9 Jan 2025 16:05:41 +0000 Subject: [PATCH 16/77] Make `FileUpload` extend `ConfigurableComponent` Use the recently added class for consistency with the other components. Also adds the tests related to the behaviour provided by the `ConfigurableComponent` class --- .../components/file-upload/file-upload.mjs | 31 ++------- .../file-upload/file-upload.puppeteer.test.js | 65 +++++++++++++++++++ 2 files changed, 69 insertions(+), 27 deletions(-) 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 index 9b4d74fb31..deb0e10ceb 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -1,15 +1,15 @@ import { closestAttributeValue } from '../../common/closest-attribute-value.mjs' -import { mergeConfigs, normaliseDataset } from '../../common/configuration.mjs' +import { ConfigurableComponent } from '../../common/configuration.mjs' import { ElementError } from '../../errors/index.mjs' -import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs' import { I18n } from '../../i18n.mjs' /** * File upload component * * @preserve + * @augments ConfigurableComponent */ -export class FileUpload extends GOVUKFrontendComponent { +export class FileUpload extends ConfigurableComponent { /** * @private */ @@ -25,14 +25,6 @@ export class FileUpload extends GOVUKFrontendComponent { */ $status - /** - * @private - * @type {FileUploadConfig} - */ - // eslint-disable-next-line - // @ts-ignore - config - /** @private */ i18n @@ -41,11 +33,7 @@ export class FileUpload extends GOVUKFrontendComponent { * @param {FileUploadConfig} [config] - File Upload config */ constructor($root, config = {}) { - super($root) - - if (!(this.$root instanceof HTMLInputElement)) { - return - } + super($root, config) if (this.$root.type !== 'file') { throw new ElementError( @@ -53,12 +41,6 @@ export class FileUpload extends GOVUKFrontendComponent { ) } - this.config = mergeConfigs( - FileUpload.defaults, - config, - normaliseDataset(FileUpload, this.$root.dataset) - ) - 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') @@ -140,11 +122,6 @@ export class FileUpload extends GOVUKFrontendComponent { // @ts-ignore const fileCount = this.$root.files.length // eslint-disable-line - // trying to appease typescript - if (!this.$status || !this.i18n) { - return - } - if (fileCount === 0) { // If there are no files, show the default selection text this.$status.innerText = this.i18n.t('filesSelectedDefault') 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 index 1975edb351..4c33a2eba0 100644 --- 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 @@ -1,3 +1,5 @@ +/* eslint-disable no-new */ + const { render } = require('@govuk-frontend/helpers/puppeteer') const { getExamples } = require('@govuk-frontend/lib/components') @@ -334,6 +336,69 @@ describe('/components/file-upload', () => { examples = await getExamples('file-upload') }) + it('can throw a SupportError if appropriate', async () => { + await expect( + render(page, 'file-upload', examples.default, { + beforeInitialisation() { + document.body.classList.remove('govuk-frontend-supported') + } + }) + ).rejects.toMatchObject({ + cause: { + name: 'SupportError', + message: + 'GOV.UK Frontend initialised without `` from template `