From 26b05e1679b999601312f1f97639efc3b8bcc9f9 Mon Sep 17 00:00:00 2001
From: beeps <hi+github@berly.kim>
Date: Thu, 12 Sep 2024 12:38:19 +0100
Subject: [PATCH] [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    | 229 ++++++++++++++++++
 .../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    |   5 +
 .../tasks/build/package.unit.test.mjs         |   1 +
 9 files changed, 321 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 78fe080e39c..a21d462b3a1 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 9343b579327..27cb7cfa3f2 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 5862ab9cc34..4b20003587b 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 00000000000..00076df49b3
--- /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 6506fde6eb8..28412c95899 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: multiple
+  - 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 a3b11c7b90c..a8276f98c88 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 %}
-  <input class="govuk-file-upload {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} govuk-file-upload--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="file"
+  <input class="govuk-file-upload {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} govuk-file-upload--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="file" data-module="govuk-file-upload"
   {%- if params.value %} value="{{ params.value }}"{% endif %}
   {%- if params.disabled %} disabled{% endif %}
   {%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
diff --git a/packages/govuk-frontend/src/govuk/init.jsdom.test.mjs b/packages/govuk-frontend/src/govuk/init.jsdom.test.mjs
index 0ccb899b447..eb64e09fdb6 100644
--- a/packages/govuk-frontend/src/govuk/init.jsdom.test.mjs
+++ b/packages/govuk-frontend/src/govuk/init.jsdom.test.mjs
@@ -13,6 +13,7 @@ jest.mock(`./components/character-count/character-count.mjs`)
 jest.mock(`./components/checkboxes/checkboxes.mjs`)
 jest.mock(`./components/error-summary/error-summary.mjs`)
 jest.mock(`./components/exit-this-page/exit-this-page.mjs`)
+jest.mock(`./components/file-upload/file-upload.mjs`)
 jest.mock(`./components/header/header.mjs`)
 jest.mock(`./components/notification-banner/notification-banner.mjs`)
 jest.mock(`./components/password-input/password-input.mjs`)
@@ -37,6 +38,7 @@ describe('initAll', () => {
     '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 2aaaf86bc0b..58a26354c96 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 20d571d47d1..462904addd6 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';