diff --git a/packages/govuk-frontend/src/govuk/all.mjs b/packages/govuk-frontend/src/govuk/all.mjs index 78fe080e39..a21d462b3a 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 9343b57932..27cb7cfa3f 100644 --- a/packages/govuk-frontend/src/govuk/all.puppeteer.test.js +++ b/packages/govuk-frontend/src/govuk/all.puppeteer.test.js @@ -56,6 +56,7 @@ describe('GOV.UK Frontend', () => { 'Checkboxes', '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..00076df49b --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -0,0 +1,229 @@ +import { closestAttributeValue } from '../../common/closest-attribute-value.mjs' +import { mergeConfigs } from '../../common/index.mjs' +import { normaliseDataset } from '../../common/normalise-dataset.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() + + if (!($input instanceof HTMLInputElement)) { + throw new ElementError({ + componentName: 'File upload', + 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 + */ + +/** + * @typedef {import('../../common/index.mjs').Schema} Schema + * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms + */ 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 6506fde6eb..b5c9466247 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 @@ -89,6 +89,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 @@ -107,13 +132,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 @@ -132,6 +150,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 a3b11c7b90..a8276f98c8 100644 --- a/packages/govuk-frontend/src/govuk/components/file-upload/template.njk +++ b/packages/govuk-frontend/src/govuk/components/file-upload/template.njk @@ -42,7 +42,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 2aaaf86bc0..58a26354c9 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' @@ -38,6 +39,7 @@ function initAll(config) { [Checkboxes], [ErrorSummary, config.errorSummary], [ExitThisPage, config.exitThisPage], + [FileUpload, config.fileUpload], [Header], [NotificationBanner, config.notificationBanner], [PasswordInput, config.passwordInput], @@ -122,6 +124,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 */ @@ -137,6 +140,8 @@ export { initAll, createAll } * @typedef {import('./components/error-summary/error-summary.mjs').ErrorSummaryConfig} ErrorSummaryConfig * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageConfig} ExitThisPageConfig * @typedef {import('./components/exit-this-page/exit-this-page.mjs').ExitThisPageTranslations} ExitThisPageTranslations + * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadConfig} FileUploadConfig + * @typedef {import('./components/file-upload/file-upload.mjs').FileUploadTranslations} FileUploadTranslations * @typedef {import('./components/notification-banner/notification-banner.mjs').NotificationBannerConfig} NotificationBannerConfig * @typedef {import('./components/password-input/password-input.mjs').PasswordInputConfig} PasswordInputConfig */ diff --git a/packages/govuk-frontend/tasks/build/package.unit.test.mjs b/packages/govuk-frontend/tasks/build/package.unit.test.mjs index 20d571d47d..462904addd 100644 --- a/packages/govuk-frontend/tasks/build/package.unit.test.mjs +++ b/packages/govuk-frontend/tasks/build/package.unit.test.mjs @@ -187,6 +187,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';