Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve handling of visual feedback for the drop zone #5605

Conversation

romaricpascal
Copy link
Member

@romaricpascal romaricpascal commented Jan 13, 2025

  • Only show the dropzone when the user drags into it rather than when entering the document. This will prevent multiple announcements when we add feedback for screenreaders, in case there's multiple FileUploads on the page
  • Only show the dropzone if the user is dragging files into it rather than any kind of content.
  • Fix disappearance of the dropzone due to many dragleave events being triggered as user drags over the different elements inside the wrapper

The component still relies on the native <input> receiving the files being dropped, as it ensures a change event gets triggered on drop (which we'd have to simulate if setting its files properties programmatically).

Thoughts

The main thing this PR had to work through are that:

  1. dragleave events are triggered for each descendant of the drop zone element, including when the mouse moves from the drop zone itself to one of the descendant. As the event bubbles, a listener on the drop zone is triggered for each element the mouse goes over within the drop zone and we need to account for that.
  2. Safari doesn't set a relatedTarget event on dragleave, meaning we can't use it when leaving the drop zone for outside the window (which can happen if the drop zone is cut by the bottom edge of the browser due to scrolling) and need to rely on the lack of dragenter event when exiting the viewport (as the mouse is not going over any element).

Regarding announcements, those only happen when the browser is in the foreground (tested in both VO + Safari, NVDA + Chrome and NVDA + Firefox, all showing consistent behaviour).

Any design adjustments will be made in a future PR once we've implemented the chosen design for the component.

Copy link

github-actions bot commented Jan 13, 2025

📋 Stats

File sizes

File Size
dist/govuk-frontend-development.min.css 119.03 KiB
dist/govuk-frontend-development.min.js 46.24 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 99.88 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 93.81 KiB
packages/govuk-frontend/dist/govuk/all.mjs 1.32 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend-component.mjs 1.74 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.css 119.02 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.js 46.23 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB
packages/govuk-frontend/dist/govuk/init.mjs 7.5 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 87.94 KiB 43.8 KiB
accordion.mjs 26.58 KiB 13.41 KiB
button.mjs 9.09 KiB 3.78 KiB
character-count.mjs 25.39 KiB 10.9 KiB
checkboxes.mjs 7.81 KiB 3.42 KiB
error-summary.mjs 10.99 KiB 4.54 KiB
exit-this-page.mjs 20.2 KiB 10.34 KiB
file-upload.mjs 18.51 KiB 9.39 KiB
header.mjs 6.46 KiB 3.22 KiB
notification-banner.mjs 9.35 KiB 3.7 KiB
password-input.mjs 18.24 KiB 8.33 KiB
radios.mjs 6.81 KiB 2.98 KiB
service-navigation.mjs 6.44 KiB 3.26 KiB
skip-link.mjs 6.4 KiB 2.76 KiB
tabs.mjs 12.04 KiB 6.67 KiB

View stats and visualisations on the review app


Action run for 5b9ca62

Copy link

github-actions bot commented Jan 13, 2025

JavaScript changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
index 341ec36d6..5a65848c9 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
@@ -759,7 +759,21 @@ class FileUpload extends ConfigurableComponent {
         const s = document.createElement("button");
         s.className = "govuk-button govuk-button--secondary govuk-file-upload__button", s.type = "button", s.innerText = this.i18n.t("selectFilesButton"), s.addEventListener("click", this.onClick.bind(this));
         const i = document.createElement("span");
-        i.className = "govuk-body govuk-file-upload__status", i.innerText = this.i18n.t("filesSelectedDefault"), i.setAttribute("role", "status"), n.insertAdjacentElement("beforeend", s), n.insertAdjacentElement("beforeend", i), this.$root.insertAdjacentElement("afterend", n), n.insertAdjacentElement("afterbegin", this.$root), this.$wrapper = n, this.$button = s, this.$status = i, this.$root.setAttribute("tabindex", "-1"), this.updateDisabledState(), this.observeDisabledState(), this.$root.addEventListener("change", this.onChange.bind(this)), this.$wrapper.addEventListener("drop", this.onDragLeaveOrDrop.bind(this)), document.addEventListener("dragenter", this.onDragEnter.bind(this)), document.addEventListener("dragleave", this.onDragLeaveOrDrop.bind(this))
+        i.className = "govuk-body govuk-file-upload__status", i.innerText = this.i18n.t("filesSelectedDefault"), i.setAttribute("role", "status"), n.insertAdjacentElement("beforeend", s), n.insertAdjacentElement("beforeend", i), this.$root.insertAdjacentElement("afterend", n), n.insertAdjacentElement("afterbegin", this.$root), this.$wrapper = n, this.$button = s, this.$status = i, this.$root.setAttribute("tabindex", "-1"), this.updateDisabledState(), this.observeDisabledState(), this.$root.addEventListener("change", this.onChange.bind(this)), this.$announcements = document.createElement("span"), this.$announcements.classList.add("govuk-file-upload-announcements"), this.$announcements.classList.add("govuk-visually-hidden"), this.$announcements.setAttribute("aria-live", "assertive"), this.$wrapper.insertAdjacentElement("afterend", this.$announcements), this.$wrapper.addEventListener("drop", this.hideDropZone.bind(this)), document.addEventListener("dragenter", this.updateDropzoneVisibility.bind(this)), document.addEventListener("dragenter", (() => {
+            this.enteredAnotherElement = !0
+        })), document.addEventListener("dragleave", (() => {
+            this.enteredAnotherElement || this.hideDropZone(), this.enteredAnotherElement = !1
+        }))
+    }
+    updateDropzoneVisibility(t) {
+        t.target instanceof Node && (this.$wrapper.contains(t.target) ? t.dataTransfer && function(t) {
+            const e = 0 === t.types.length,
+                n = t.types.some((t => "Files" === t));
+            return e || n
+        }(t.dataTransfer) && (this.$wrapper.classList.contains("govuk-file-upload-wrapper--show-dropzone") || (this.$wrapper.classList.add("govuk-file-upload-wrapper--show-dropzone"), this.$announcements.innerText = this.i18n.t("dropZoneEntered"))) : this.$wrapper.classList.contains("govuk-file-upload-wrapper--show-dropzone") && this.hideDropZone())
+    }
+    hideDropZone() {
+        this.$wrapper.classList.remove("govuk-file-upload-wrapper--show-dropzone"), this.$announcements.innerText = this.i18n.t("dropZoneLeft")
     }
     onChange() {
         const t = this.$root.files.length;
@@ -778,12 +792,6 @@ class FileUpload extends ConfigurableComponent {
     onClick() {
         this.$label.click()
     }
-    onDragEnter(t) {
-        console.log(t), this.$wrapper.classList.add("govuk-file-upload-wrapper--show-dropzone")
-    }
-    onDragLeaveOrDrop() {
-        this.$wrapper.classList.remove("govuk-file-upload-wrapper--show-dropzone")
-    }
     observeDisabledState() {
         new MutationObserver((t => {
             for (const e of t) console.log("mutation", e), "attributes" === e.type && "disabled" === e.attributeName && this.updateDisabledState()
@@ -802,7 +810,9 @@ FileUpload.moduleName = "govuk-file-upload", FileUpload.defaults = Object.freeze
         filesSelected: {
             one: "%{count} file chosen",
             other: "%{count} files chosen"
-        }
+        },
+        dropZoneEntered: "Entered drop zone",
+        dropZoneLeft: "Left drop zone"
     }
 }), FileUpload.schema = Object.freeze({
     properties: {

Action run for 5b9ca62

Copy link

github-actions bot commented Jan 13, 2025

Other changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.js b/packages/govuk-frontend/dist/govuk/all.bundle.js
index 63161d0ec..11771add6 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -1700,9 +1700,48 @@
       this.updateDisabledState();
       this.observeDisabledState();
       this.$root.addEventListener('change', this.onChange.bind(this));
-      this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
-      document.addEventListener('dragenter', this.onDragEnter.bind(this));
-      document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+      this.$announcements = document.createElement('span');
+      this.$announcements.classList.add('govuk-file-upload-announcements');
+      this.$announcements.classList.add('govuk-visually-hidden');
+      this.$announcements.setAttribute('aria-live', 'assertive');
+      this.$wrapper.insertAdjacentElement('afterend', this.$announcements);
+      this.$wrapper.addEventListener('drop', this.hideDropZone.bind(this));
+      document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+      document.addEventListener('dragenter', () => {
+        this.enteredAnotherElement = true;
+      });
+      document.addEventListener('dragleave', () => {
+        if (!this.enteredAnotherElement) {
+          this.hideDropZone();
+        }
+        this.enteredAnotherElement = false;
+      });
+    }
+
+    /**
+     * Updates the visibility of the dropzone as users enters the various elements on the page
+     *
+     * @param {DragEvent} event - The `dragenter` event
+     */
+    updateDropzoneVisibility(event) {
+      if (event.target instanceof Node) {
+        if (this.$wrapper.contains(event.target)) {
+          if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+            if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+              this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+              this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+            }
+          }
+        } else {
+          if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+            this.hideDropZone();
+          }
+        }
+      }
+    }
+    hideDropZone() {
+      this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+      this.$announcements.innerText = this.i18n.t('dropZoneLeft');
     }
     onChange() {
       const fileCount = this.$root.files.length;
@@ -1729,20 +1768,6 @@
     onClick() {
       this.$label.click();
     }
-
-    /**
-     * 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
-     */
-    onDragEnter(event) {
-      console.log(event);
-      this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
-    }
-    onDragLeaveOrDrop() {
-      this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
-    }
     observeDisabledState() {
       const observer = new MutationObserver(mutationList => {
         for (const mutation of mutationList) {
@@ -1760,6 +1785,31 @@
       this.$button.disabled = this.$root.disabled;
     }
   }
+  FileUpload.moduleName = 'govuk-file-upload';
+  FileUpload.defaults = Object.freeze({
+    i18n: {
+      selectFilesButton: 'Choose file',
+      filesSelectedDefault: 'No file chosen',
+      filesSelected: {
+        one: '%{count} file chosen',
+        other: '%{count} files chosen'
+      },
+      dropZoneEntered: 'Entered drop zone',
+      dropZoneLeft: 'Left drop zone'
+    }
+  });
+  FileUpload.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
+  function isContainingFiles(dataTransfer) {
+    const hasNoTypesInfo = dataTransfer.types.length === 0;
+    const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+    return hasNoTypesInfo || isDraggingFiles;
+  }
 
   /**
    * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
@@ -1783,30 +1833,16 @@
    * @property {string} [selectFiles] - Text of button that opens file browser
    * @property {TranslationPluralForms} [filesSelected] - Text indicating how
    *   many files have been selected
+   * @property {string} [dropZoneEntered] - Text announced to assistive technology
+   *   when users entered the drop zone while dragging
+   * @property {string} [dropZoneLeft] - Text announced to assistive technology
+   *   when users left the drop zone while dragging
    */
 
   /**
    * @typedef {import('../../common/configuration.mjs').Schema} Schema
    * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
    */
-  FileUpload.moduleName = 'govuk-file-upload';
-  FileUpload.defaults = Object.freeze({
-    i18n: {
-      selectFilesButton: 'Choose file',
-      filesSelectedDefault: 'No file chosen',
-      filesSelected: {
-        one: '%{count} file chosen',
-        other: '%{count} files chosen'
-      }
-    }
-  });
-  FileUpload.schema = Object.freeze({
-    properties: {
-      i18n: {
-        type: 'object'
-      }
-    }
-  });
 
   /**
    * Header component
diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.mjs b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
index 34f9a09f1..ebc802c20 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -1694,9 +1694,48 @@ class FileUpload extends ConfigurableComponent {
     this.updateDisabledState();
     this.observeDisabledState();
     this.$root.addEventListener('change', this.onChange.bind(this));
-    this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
-    document.addEventListener('dragenter', this.onDragEnter.bind(this));
-    document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+    this.$announcements = document.createElement('span');
+    this.$announcements.classList.add('govuk-file-upload-announcements');
+    this.$announcements.classList.add('govuk-visually-hidden');
+    this.$announcements.setAttribute('aria-live', 'assertive');
+    this.$wrapper.insertAdjacentElement('afterend', this.$announcements);
+    this.$wrapper.addEventListener('drop', this.hideDropZone.bind(this));
+    document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+    document.addEventListener('dragenter', () => {
+      this.enteredAnotherElement = true;
+    });
+    document.addEventListener('dragleave', () => {
+      if (!this.enteredAnotherElement) {
+        this.hideDropZone();
+      }
+      this.enteredAnotherElement = false;
+    });
+  }
+
+  /**
+   * Updates the visibility of the dropzone as users enters the various elements on the page
+   *
+   * @param {DragEvent} event - The `dragenter` event
+   */
+  updateDropzoneVisibility(event) {
+    if (event.target instanceof Node) {
+      if (this.$wrapper.contains(event.target)) {
+        if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+          if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+            this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+            this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+          }
+        }
+      } else {
+        if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+          this.hideDropZone();
+        }
+      }
+    }
+  }
+  hideDropZone() {
+    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+    this.$announcements.innerText = this.i18n.t('dropZoneLeft');
   }
   onChange() {
     const fileCount = this.$root.files.length;
@@ -1723,20 +1762,6 @@ class FileUpload extends ConfigurableComponent {
   onClick() {
     this.$label.click();
   }
-
-  /**
-   * 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
-   */
-  onDragEnter(event) {
-    console.log(event);
-    this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
-  }
-  onDragLeaveOrDrop() {
-    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
-  }
   observeDisabledState() {
     const observer = new MutationObserver(mutationList => {
       for (const mutation of mutationList) {
@@ -1754,6 +1779,31 @@ class FileUpload extends ConfigurableComponent {
     this.$button.disabled = this.$root.disabled;
   }
 }
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+  i18n: {
+    selectFilesButton: 'Choose file',
+    filesSelectedDefault: 'No file chosen',
+    filesSelected: {
+      one: '%{count} file chosen',
+      other: '%{count} files chosen'
+    },
+    dropZoneEntered: 'Entered drop zone',
+    dropZoneLeft: 'Left drop zone'
+  }
+});
+FileUpload.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+function isContainingFiles(dataTransfer) {
+  const hasNoTypesInfo = dataTransfer.types.length === 0;
+  const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+  return hasNoTypesInfo || isDraggingFiles;
+}
 
 /**
  * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
@@ -1777,30 +1827,16 @@ class FileUpload extends ConfigurableComponent {
  * @property {string} [selectFiles] - Text of button that opens file browser
  * @property {TranslationPluralForms} [filesSelected] - Text indicating how
  *   many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ *   when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ *   when users left the drop zone while dragging
  */
 
 /**
  * @typedef {import('../../common/configuration.mjs').Schema} Schema
  * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
  */
-FileUpload.moduleName = 'govuk-file-upload';
-FileUpload.defaults = Object.freeze({
-  i18n: {
-    selectFilesButton: 'Choose file',
-    filesSelectedDefault: 'No file chosen',
-    filesSelected: {
-      one: '%{count} file chosen',
-      other: '%{count} files chosen'
-    }
-  }
-});
-FileUpload.schema = Object.freeze({
-  properties: {
-    i18n: {
-      type: 'object'
-    }
-  }
-});
 
 /**
  * Header component
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js
index 87cf44235..11a85cff2 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.js
@@ -527,9 +527,48 @@
       this.updateDisabledState();
       this.observeDisabledState();
       this.$root.addEventListener('change', this.onChange.bind(this));
-      this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
-      document.addEventListener('dragenter', this.onDragEnter.bind(this));
-      document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+      this.$announcements = document.createElement('span');
+      this.$announcements.classList.add('govuk-file-upload-announcements');
+      this.$announcements.classList.add('govuk-visually-hidden');
+      this.$announcements.setAttribute('aria-live', 'assertive');
+      this.$wrapper.insertAdjacentElement('afterend', this.$announcements);
+      this.$wrapper.addEventListener('drop', this.hideDropZone.bind(this));
+      document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+      document.addEventListener('dragenter', () => {
+        this.enteredAnotherElement = true;
+      });
+      document.addEventListener('dragleave', () => {
+        if (!this.enteredAnotherElement) {
+          this.hideDropZone();
+        }
+        this.enteredAnotherElement = false;
+      });
+    }
+
+    /**
+     * Updates the visibility of the dropzone as users enters the various elements on the page
+     *
+     * @param {DragEvent} event - The `dragenter` event
+     */
+    updateDropzoneVisibility(event) {
+      if (event.target instanceof Node) {
+        if (this.$wrapper.contains(event.target)) {
+          if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+            if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+              this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+              this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+            }
+          }
+        } else {
+          if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+            this.hideDropZone();
+          }
+        }
+      }
+    }
+    hideDropZone() {
+      this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+      this.$announcements.innerText = this.i18n.t('dropZoneLeft');
     }
     onChange() {
       const fileCount = this.$root.files.length;
@@ -556,20 +595,6 @@
     onClick() {
       this.$label.click();
     }
-
-    /**
-     * 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
-     */
-    onDragEnter(event) {
-      console.log(event);
-      this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
-    }
-    onDragLeaveOrDrop() {
-      this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
-    }
     observeDisabledState() {
       const observer = new MutationObserver(mutationList => {
         for (const mutation of mutationList) {
@@ -587,6 +612,31 @@
       this.$button.disabled = this.$root.disabled;
     }
   }
+  FileUpload.moduleName = 'govuk-file-upload';
+  FileUpload.defaults = Object.freeze({
+    i18n: {
+      selectFilesButton: 'Choose file',
+      filesSelectedDefault: 'No file chosen',
+      filesSelected: {
+        one: '%{count} file chosen',
+        other: '%{count} files chosen'
+      },
+      dropZoneEntered: 'Entered drop zone',
+      dropZoneLeft: 'Left drop zone'
+    }
+  });
+  FileUpload.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
+  function isContainingFiles(dataTransfer) {
+    const hasNoTypesInfo = dataTransfer.types.length === 0;
+    const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+    return hasNoTypesInfo || isDraggingFiles;
+  }
 
   /**
    * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
@@ -610,30 +660,16 @@
    * @property {string} [selectFiles] - Text of button that opens file browser
    * @property {TranslationPluralForms} [filesSelected] - Text indicating how
    *   many files have been selected
+   * @property {string} [dropZoneEntered] - Text announced to assistive technology
+   *   when users entered the drop zone while dragging
+   * @property {string} [dropZoneLeft] - Text announced to assistive technology
+   *   when users left the drop zone while dragging
    */
 
   /**
    * @typedef {import('../../common/configuration.mjs').Schema} Schema
    * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
    */
-  FileUpload.moduleName = 'govuk-file-upload';
-  FileUpload.defaults = Object.freeze({
-    i18n: {
-      selectFilesButton: 'Choose file',
-      filesSelectedDefault: 'No file chosen',
-      filesSelected: {
-        one: '%{count} file chosen',
-        other: '%{count} files chosen'
-      }
-    }
-  });
-  FileUpload.schema = Object.freeze({
-    properties: {
-      i18n: {
-        type: 'object'
-      }
-    }
-  });
 
   exports.FileUpload = FileUpload;
 
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs
index 253fa0afd..b21914e73 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.bundle.mjs
@@ -521,9 +521,48 @@ class FileUpload extends ConfigurableComponent {
     this.updateDisabledState();
     this.observeDisabledState();
     this.$root.addEventListener('change', this.onChange.bind(this));
-    this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
-    document.addEventListener('dragenter', this.onDragEnter.bind(this));
-    document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+    this.$announcements = document.createElement('span');
+    this.$announcements.classList.add('govuk-file-upload-announcements');
+    this.$announcements.classList.add('govuk-visually-hidden');
+    this.$announcements.setAttribute('aria-live', 'assertive');
+    this.$wrapper.insertAdjacentElement('afterend', this.$announcements);
+    this.$wrapper.addEventListener('drop', this.hideDropZone.bind(this));
+    document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+    document.addEventListener('dragenter', () => {
+      this.enteredAnotherElement = true;
+    });
+    document.addEventListener('dragleave', () => {
+      if (!this.enteredAnotherElement) {
+        this.hideDropZone();
+      }
+      this.enteredAnotherElement = false;
+    });
+  }
+
+  /**
+   * Updates the visibility of the dropzone as users enters the various elements on the page
+   *
+   * @param {DragEvent} event - The `dragenter` event
+   */
+  updateDropzoneVisibility(event) {
+    if (event.target instanceof Node) {
+      if (this.$wrapper.contains(event.target)) {
+        if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+          if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+            this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+            this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+          }
+        }
+      } else {
+        if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+          this.hideDropZone();
+        }
+      }
+    }
+  }
+  hideDropZone() {
+    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+    this.$announcements.innerText = this.i18n.t('dropZoneLeft');
   }
   onChange() {
     const fileCount = this.$root.files.length;
@@ -550,20 +589,6 @@ class FileUpload extends ConfigurableComponent {
   onClick() {
     this.$label.click();
   }
-
-  /**
-   * 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
-   */
-  onDragEnter(event) {
-    console.log(event);
-    this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
-  }
-  onDragLeaveOrDrop() {
-    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
-  }
   observeDisabledState() {
     const observer = new MutationObserver(mutationList => {
       for (const mutation of mutationList) {
@@ -581,6 +606,31 @@ class FileUpload extends ConfigurableComponent {
     this.$button.disabled = this.$root.disabled;
   }
 }
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+  i18n: {
+    selectFilesButton: 'Choose file',
+    filesSelectedDefault: 'No file chosen',
+    filesSelected: {
+      one: '%{count} file chosen',
+      other: '%{count} files chosen'
+    },
+    dropZoneEntered: 'Entered drop zone',
+    dropZoneLeft: 'Left drop zone'
+  }
+});
+FileUpload.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+function isContainingFiles(dataTransfer) {
+  const hasNoTypesInfo = dataTransfer.types.length === 0;
+  const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+  return hasNoTypesInfo || isDraggingFiles;
+}
 
 /**
  * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
@@ -604,30 +654,16 @@ class FileUpload extends ConfigurableComponent {
  * @property {string} [selectFiles] - Text of button that opens file browser
  * @property {TranslationPluralForms} [filesSelected] - Text indicating how
  *   many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ *   when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ *   when users left the drop zone while dragging
  */
 
 /**
  * @typedef {import('../../common/configuration.mjs').Schema} Schema
  * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
  */
-FileUpload.moduleName = 'govuk-file-upload';
-FileUpload.defaults = Object.freeze({
-  i18n: {
-    selectFilesButton: 'Choose file',
-    filesSelectedDefault: 'No file chosen',
-    filesSelected: {
-      one: '%{count} file chosen',
-      other: '%{count} files chosen'
-    }
-  }
-});
-FileUpload.schema = Object.freeze({
-  properties: {
-    i18n: {
-      type: 'object'
-    }
-  }
-});
 
 export { FileUpload };
 //# sourceMappingURL=file-upload.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
index 1ddbc3dc5..c859f2ebd 100644
--- a/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/file-upload/file-upload.mjs
@@ -50,9 +50,48 @@ class FileUpload extends ConfigurableComponent {
     this.updateDisabledState();
     this.observeDisabledState();
     this.$root.addEventListener('change', this.onChange.bind(this));
-    this.$wrapper.addEventListener('drop', this.onDragLeaveOrDrop.bind(this));
-    document.addEventListener('dragenter', this.onDragEnter.bind(this));
-    document.addEventListener('dragleave', this.onDragLeaveOrDrop.bind(this));
+    this.$announcements = document.createElement('span');
+    this.$announcements.classList.add('govuk-file-upload-announcements');
+    this.$announcements.classList.add('govuk-visually-hidden');
+    this.$announcements.setAttribute('aria-live', 'assertive');
+    this.$wrapper.insertAdjacentElement('afterend', this.$announcements);
+    this.$wrapper.addEventListener('drop', this.hideDropZone.bind(this));
+    document.addEventListener('dragenter', this.updateDropzoneVisibility.bind(this));
+    document.addEventListener('dragenter', () => {
+      this.enteredAnotherElement = true;
+    });
+    document.addEventListener('dragleave', () => {
+      if (!this.enteredAnotherElement) {
+        this.hideDropZone();
+      }
+      this.enteredAnotherElement = false;
+    });
+  }
+
+  /**
+   * Updates the visibility of the dropzone as users enters the various elements on the page
+   *
+   * @param {DragEvent} event - The `dragenter` event
+   */
+  updateDropzoneVisibility(event) {
+    if (event.target instanceof Node) {
+      if (this.$wrapper.contains(event.target)) {
+        if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
+          if (!this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+            this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
+            this.$announcements.innerText = this.i18n.t('dropZoneEntered');
+          }
+        }
+      } else {
+        if (this.$wrapper.classList.contains('govuk-file-upload-wrapper--show-dropzone')) {
+          this.hideDropZone();
+        }
+      }
+    }
+  }
+  hideDropZone() {
+    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
+    this.$announcements.innerText = this.i18n.t('dropZoneLeft');
   }
   onChange() {
     const fileCount = this.$root.files.length;
@@ -79,20 +118,6 @@ class FileUpload extends ConfigurableComponent {
   onClick() {
     this.$label.click();
   }
-
-  /**
-   * 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
-   */
-  onDragEnter(event) {
-    console.log(event);
-    this.$wrapper.classList.add('govuk-file-upload-wrapper--show-dropzone');
-  }
-  onDragLeaveOrDrop() {
-    this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone');
-  }
   observeDisabledState() {
     const observer = new MutationObserver(mutationList => {
       for (const mutation of mutationList) {
@@ -110,6 +135,31 @@ class FileUpload extends ConfigurableComponent {
     this.$button.disabled = this.$root.disabled;
   }
 }
+FileUpload.moduleName = 'govuk-file-upload';
+FileUpload.defaults = Object.freeze({
+  i18n: {
+    selectFilesButton: 'Choose file',
+    filesSelectedDefault: 'No file chosen',
+    filesSelected: {
+      one: '%{count} file chosen',
+      other: '%{count} files chosen'
+    },
+    dropZoneEntered: 'Entered drop zone',
+    dropZoneLeft: 'Left drop zone'
+  }
+});
+FileUpload.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
+function isContainingFiles(dataTransfer) {
+  const hasNoTypesInfo = dataTransfer.types.length === 0;
+  const isDraggingFiles = dataTransfer.types.some(type => type === 'Files');
+  return hasNoTypesInfo || isDraggingFiles;
+}
 
 /**
  * @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
@@ -133,30 +183,16 @@ class FileUpload extends ConfigurableComponent {
  * @property {string} [selectFiles] - Text of button that opens file browser
  * @property {TranslationPluralForms} [filesSelected] - Text indicating how
  *   many files have been selected
+ * @property {string} [dropZoneEntered] - Text announced to assistive technology
+ *   when users entered the drop zone while dragging
+ * @property {string} [dropZoneLeft] - Text announced to assistive technology
+ *   when users left the drop zone while dragging
  */
 
 /**
  * @typedef {import('../../common/configuration.mjs').Schema} Schema
  * @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
  */
-FileUpload.moduleName = 'govuk-file-upload';
-FileUpload.defaults = Object.freeze({
-  i18n: {
-    selectFilesButton: 'Choose file',
-    filesSelectedDefault: 'No file chosen',
-    filesSelected: {
-      one: '%{count} file chosen',
-      other: '%{count} files chosen'
-    }
-  }
-});
-FileUpload.schema = Object.freeze({
-  properties: {
-    i18n: {
-      type: 'object'
-    }
-  }
-});
 
 export { FileUpload };
 //# sourceMappingURL=file-upload.mjs.map

Action run for 5b9ca62

@romaricpascal romaricpascal force-pushed the enhanced-file-upload-reject-non-files-drops branch from 4043936 to 7c5e8e6 Compare January 14, 2025 18:43
@romaricpascal romaricpascal force-pushed the enhanced-file-upload-reject-non-files-drops branch 2 times, most recently from 7c5e8e6 to b1cfdbd Compare January 14, 2025 19:28
@romaricpascal romaricpascal changed the title [SPIKE] Improve handling of drag'n'drop Improve handling of drag'n'drop Jan 14, 2025
@romaricpascal romaricpascal changed the title Improve handling of drag'n'drop Improve handling of visual feedback for the drop zone Jan 14, 2025
- Only show the dropzone when the user drags into it rather than when entering the document. This will prevent multiple announcements when we add feedback for screenreaders, in case there's multiple `FileUpload`s on the page
- Add a test to check if the user is dragging files before showing dropzone
- Fix disappearance of the dropzone due to many `dragleave` events being triggered as user drags over the different elements inside the wrapper
- Separate the handler of `drop` event as it doesn't need the same complexity as the `dragleave` one before hiding the dropzone.

The component still relies on the native `<input>` receiving the files being dropped, as it ensures a `change` event gets triggered on drop (which we'd have to simulate if setting its `files` properties programmatically).
@romaricpascal romaricpascal force-pushed the enhanced-file-upload-reject-non-files-drops branch from b1cfdbd to 3e25b66 Compare January 14, 2025 19:38
@romaricpascal romaricpascal marked this pull request as ready for review January 14, 2025 19:48
It happened when dragging from the button to the span or the opposite,
because Safari does not fill the `relatedTarget` on `dragleave`, making
our code believe user had left the window.

To accomodate for that, we use `dragenter` to also hide the drop zone
when entering an element that's not the wrapper.

This may still leave a gap where the component is at the edge of the viewport,
either because of scrolling or in a iframe. Will explore in next commit.
@romaricpascal
Copy link
Member Author

Putting this back in draft while I figure how to handle Safari not filling in relatedTarget on dragleave, which prevents from detecting if you've left a child of the wrapper for another (for example, moving from over the <button> to the <span> with the text), moved outside of the wrapper or moved outside the page entirely.

Unfortunately, dragenter fires before dragleave, so we can't use it to set the class again after it's been deleted. dragover may be our only option to set the class consistently while the user is dragging over the dropzone, even if it fires quite often (and we can't throttle it at the risk of a flicker between a dragleave and the next time the throttled call runs).

mouseleave may help, but needs testing of whether it's triggered on touch devices.

@romaricpascal romaricpascal marked this pull request as draft January 17, 2025 11:21
@romaricpascal romaricpascal force-pushed the enhanced-file-upload-reject-non-files-drops branch from 2ffa565 to e412fa7 Compare January 17, 2025 19:04
Announces when users enter or leave the drop zone.

Note: this seems to only be announced by Voice Over
when Safari is in the foreground, even when using `aria-live="assertive"`
@romaricpascal romaricpascal force-pushed the enhanced-file-upload-reject-non-files-drops branch from e412fa7 to a6fb4f6 Compare January 17, 2025 19:21
@romaricpascal romaricpascal force-pushed the enhanced-file-upload-reject-non-files-drops branch from 8f53794 to affc0f9 Compare January 21, 2025 10:18
@romaricpascal romaricpascal force-pushed the enhanced-file-upload-reject-non-files-drops branch from affc0f9 to 5b9ca62 Compare January 21, 2025 10:22
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-5605 January 21, 2025 10:23 Inactive
@romaricpascal romaricpascal linked an issue Jan 21, 2025 that may be closed by this pull request
1 task
@romaricpascal romaricpascal marked this pull request as ready for review January 21, 2025 10:30
Copy link
Contributor

@patrickpatrickpatrick patrickpatrickpatrick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, tested and works as expected

@romaricpascal romaricpascal merged commit ffeb162 into spike-enhanced-file-upload Jan 21, 2025
49 checks passed
@romaricpascal romaricpascal deleted the enhanced-file-upload-reject-non-files-drops branch January 21, 2025 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Process only drag'n'drop events containing files
3 participants