diff --git a/packages/govuk-frontend/src/govuk/all.mjs b/packages/govuk-frontend/src/govuk/all.mjs index 684d6984c9..cffedf4fd8 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..309ea05fd5 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,54 @@ cursor: not-allowed; } } + + .govuk-file-upload-wrapper { + display: inline-flex; + align-items: baseline; + position: relative; + } + + .govuk-file-upload-wrapper--show-dropzone { + $dropzone-padding: govuk-spacing(2); + $dropzone-offset: $dropzone-padding + $govuk-border-width-form-element; + + // Add negative margins to all sides so that content doesn't jump due to + // the addition of the padding and border. + margin: -$dropzone-offset; + padding: $dropzone-padding; + border: $govuk-border-width-form-element dashed $govuk-input-border-colour; + background-color: $govuk-body-background-colour; + + .govuk-file-upload__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..d4d5c0f147 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs @@ -0,0 +1,271 @@ +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 + */ + $wrapper + + /** + * @private + */ + $button + + /** + * @private + */ + $status + + /** @private */ + i18n + + /** + * @param {Element | null} $root - File input element + * @param {FileUploadConfig} [config] - File Upload config + */ + constructor($root, config = {}) { + super($root, config) + + if (this.$root.type !== 'file') { + throw new ElementError( + formatErrorMessage( + FileUpload, + 'Form field must be an input of type `file`.' + ) + ) + } + + 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') + }) + + this.$label = this.findLabel() + + // Wrapping element. This defines the boundaries of our drag and drop area. + const $wrapper = document.createElement('div') + $wrapper.className = 'govuk-file-upload-wrapper' + + // 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.$root.insertAdjacentElement('afterend', $wrapper) + + // Move the native file input to inside of the wrapper + $wrapper.insertAdjacentElement('afterbegin', this.$root) + + // Make all these new variables available to the module + this.$wrapper = $wrapper + this.$button = $button + this.$status = $status + + // 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)) + + // 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('dragenter', this.onDragEnter.bind(this)) + document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this)) + } + + /** + * Check if the value of the underlying input has changed + */ + onChange() { + const fileCount = this.$root.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.$root.files[0].name + } else { + // Otherwise, tell the user how many files are selected + this.$status.innerText = this.i18n.t('filesSelected', { + count: fileCount + }) + } + } + + /** + * Looks up the `