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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 121 additions & 28 deletions packages/govuk-frontend/src/govuk/components/file-upload/file-upload.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,102 @@ export class FileUpload extends ConfigurableComponent {
// 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))
// Handle drop zone visibility
// A live region to announce when users enter or leave the drop zone
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)

// The easy bit, when dropping hide the dropzone
//
// Note: the component relies on the native behaviour to get the files
// being dragged set as value of the `<input>`. This allows a `change`
// event to be automatically fired from the element and saves us from having
// to do anything more than hiding the dropzone on drop.
this.$wrapper.addEventListener('drop', this.hideDropZone.bind(this))

// While user is dragging, it gets a little more complex because of Safari.
// Safari doesn't fill `relatedTarget` on `dragleave` (nor `dragenter`).
// This means we can't use `relatedTarget` to:
// - check if the user is still within the wrapper
// (`relatedTarget` being a descendant of the wrapper)
// - check if the user is still over the viewport
// (`relatedTarget` being null if outside)

// Thanks to `dragenter` bubbling, we can listen on the `document` with a
// single function and update the visibility based on whether we entered a
// node inside or outside the wrapper.
document.addEventListener(
'dragenter',
this.updateDropzoneVisibility.bind(this)
)

// To detect if we're outside the document, we can track if there was a
// `dragenter` event preceding a `dragleave`. If there wasn't, this means
// we're outside the document.
//
// The order of events is guaranteed by the HTML specs:
// https://html.spec.whatwg.org/multipage/dnd.html#drag-and-drop-processing-model
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) {
// DOM interfaces only type `event.target` as `EventTarget`
// so we first need to make sure it's a `Node`
if (event.target instanceof Node) {
if (this.$wrapper.contains(event.target)) {
if (event.dataTransfer && isContainingFiles(event.dataTransfer)) {
// Only update the class and make the announcement if not already visible
// to avoid repeated announcements on NVDA (2024.4) + Firefox (133)
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 {
// Only hide the dropzone if it is visible to prevent announcing user
// left the drop zone when they enter the page but haven't reached yet
// the file upload component
if (
this.$wrapper.classList.contains(
'govuk-file-upload-wrapper--show-dropzone'
)
) {
this.hideDropZone()
}
}
}
}

// 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))
/**
* Hides the dropzone once user has dropped files on the `<input>`
*/
hideDropZone() {
this.$wrapper.classList.remove('govuk-file-upload-wrapper--show-dropzone')
this.$announcements.innerText = this.i18n.t('dropZoneLeft')
}

/**
Expand Down Expand Up @@ -153,28 +243,6 @@ export class FileUpload extends ConfigurableComponent {
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) {
// Check if the thing being dragged is a file (and not text or something
// else), we only want to indicate files.
console.log(event)

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')
}

/**
* Create a mutation observer to check if the input's attributes altered.
*/
Expand Down Expand Up @@ -224,7 +292,9 @@ export class FileUpload extends ConfigurableComponent {
// instead, however it's here for coverage's sake
one: '%{count} file chosen',
other: '%{count} files chosen'
}
},
dropZoneEntered: 'Entered drop zone',
dropZoneLeft: 'Left drop zone'
}
})

Expand All @@ -241,6 +311,25 @@ export class FileUpload extends ConfigurableComponent {
})
}

/**
* Checks if the given `DataTransfer` contains files
*
* @internal
* @param {DataTransfer} dataTransfer - The `DataTransfer` to check
* @returns {boolean} - `true` if it contains files or we can't infer it, `false` otherwise
*/
function isContainingFiles(dataTransfer) {
// Safari sometimes does not provide info about types :'(
// In which case best not to assume anything and try to set the files
const hasNoTypesInfo = dataTransfer.types.length === 0

// When dragging images, there's a mix of mime types + Files
// which we can't assign to the native input
const isDraggingFiles = dataTransfer.types.some((type) => type === 'Files')

return hasNoTypesInfo || isDraggingFiles
}

/**
* @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
*/
Expand All @@ -263,6 +352,10 @@ export 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
*/

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,108 @@ describe('/components/file-upload', () => {
})
})

describe('dropzone', () => {
let $wrapper
let $announcements
let wrapperBoundingBox

// Shared data to drag on the element
const dragData = {
items: [],
files: [__filename],
dragOperationsMask: 1 // Copy
}

const selectorDropzoneVisible =
'.govuk-file-upload-wrapper.govuk-file-upload-wrapper--show-dropzone'
const selectorDropzoneHidden =
'.govuk-file-upload-wrapper:not(.govuk-file-upload-wrapper--show-dropzone)'

beforeEach(async () => {
await render(page, 'file-upload', examples.default)

$wrapper = await page.$('.govuk-file-upload-wrapper')
wrapperBoundingBox = await $wrapper.boundingBox()

$announcements = await page.$('.govuk-file-upload-announcements')
})

it('is not shown by default', async () => {
await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
await expect(
$announcements.evaluate((e) => e.textContent)
).resolves.toBe('')
})

it('gets shown when entering the field', async () => {
// Add a little pixel to make sure we're effectively within the element
await page.mouse.dragEnter(
{ x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
structuredClone(dragData)
)

await expect(page.$(selectorDropzoneVisible)).resolves.toBeTruthy()
await expect(
$announcements.evaluate((e) => e.textContent)
).resolves.toBe('Entered drop zone')
})

it('gets hidden when dropping on the field', async () => {
// Add a little pixel to make sure we're effectively within the element
await page.mouse.drop(
{ x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
structuredClone(dragData)
)

await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
// The presence of 'Left drop zone' confirms we handled the leaving of the drop zone
// rather than being in the initial state
await expect(
$announcements.evaluate((e) => e.textContent)
).resolves.toBe('Left drop zone')
})

it('gets hidden when dragging a file and leaving the field', async () => {
// Add a little pixel to make sure we're effectively within the element
await page.mouse.dragEnter(
{ x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
structuredClone(dragData)
)

// Move enough to the left to be out of the wrapper properly
// but not up or down in case there's other elements in the flow of the page
await page.mouse.dragEnter(
{ x: wrapperBoundingBox.x - 20, y: wrapperBoundingBox.y },
structuredClone(dragData)
)

await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
await expect(
$announcements.evaluate((e) => e.textContent)
).resolves.toBe('Left drop zone')
})

it('gets hidden when dragging a file and leaving the document', async () => {
// Add a little pixel to make sure we're effectively within the element
await page.mouse.dragEnter(
{ x: wrapperBoundingBox.x + 1, y: wrapperBoundingBox.y + 1 },
structuredClone(dragData)
)

// It doesn't seem doable to make Puppeteer drag outside the viewport
// so instead, we can only mock two 'dragleave' events
await page.$eval('.govuk-file-upload-wrapper', ($el) => {
$el.dispatchEvent(new Event('dragleave', { bubbles: true }))
$el.dispatchEvent(new Event('dragleave', { bubbles: true }))
})

await expect(page.$(selectorDropzoneHidden)).resolves.toBeTruthy()
await expect(
$announcements.evaluate((e) => e.textContent)
).resolves.toBe('Left drop zone')
})
})

describe('i18n', () => {
beforeEach(async () => {
await render(page, 'file-upload', examples.translated)
Expand Down