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

[SPIKE] Explore progressively enhanced file upload component #5305

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7f195de
[WIP] Spike progressively enhanced file upload
querkmachine Sep 12, 2024
59347a3
click label instead of input
owenatgov Oct 4, 2024
5842d17
Add aria-hidden and tabindex -1 to input
owenatgov Oct 16, 2024
b9f5a33
Add `multiple` Nunjucks parameter
querkmachine Oct 18, 2024
5b13681
Tweaks to dropzone styles
querkmachine Oct 18, 2024
84dce87
Remove setting aria-hidden
querkmachine Oct 21, 2024
2735c9e
Syncronise disabled state between input and button
querkmachine Oct 21, 2024
5ae53c0
Add i18n parameters
querkmachine Oct 21, 2024
e7d9ad4
Write JavaScript functionality tests
querkmachine Oct 22, 2024
1d09d76
Fix component not rendering plural objects correctly
querkmachine Oct 22, 2024
ea87121
Add Nunjucks parameter documentation
querkmachine Oct 22, 2024
834d32e
Add tests for disable state syncronisation
querkmachine Oct 22, 2024
e16c1fe
Show dropzone when dragover page and not input
querkmachine Oct 24, 2024
9c6749f
Change dragover to dragenter
querkmachine Oct 25, 2024
63046bf
Add tests for initialisation errors specific to the component
romaricpascal Jan 9, 2025
e749a68
Make `FileUpload` extend `ConfigurableComponent`
romaricpascal Jan 9, 2025
6b4d4fa
Format error message consistently
romaricpascal Jan 9, 2025
9abadc5
Merge pull request #5590 from alphagov/enhanced-file-upload-base-class
romaricpascal Jan 10, 2025
9f0ea60
Remove comments ignoring TypeScript or linting errors
romaricpascal Jan 10, 2025
5d24ced
Remove unnecessary `if` statements
romaricpascal Jan 10, 2025
3410e93
Encapsulate lookup of the `<label>` element
romaricpascal Jan 10, 2025
8890404
Remove unnecessary check for existence of the `files` attribute
romaricpascal Jan 10, 2025
d52283f
Merge pull request #5592 from alphagov/enhanced-file-upload-linting-fix
romaricpascal Jan 13, 2025
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
1 change: 1 addition & 0 deletions packages/govuk-frontend/src/govuk/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions packages/govuk-frontend/src/govuk/all.puppeteer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe('GOV.UK Frontend', () => {
'ConfigurableComponent',
'ErrorSummary',
'ExitThisPage',
'FileUpload',
'Header',
'NotificationBanner',
'PasswordInput',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<FileUploadConfig,HTMLFileInputElement>
*/
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 `<label>` element associated to the field
*
* @private
* @returns {HTMLElement} The `<label>` element associated to the field
* @throws {ElementError} If the `<label>` cannot be found
*/
findLabel() {
// Use `label` in the selector so TypeScript knows the type fo `HTMLElement`
const $label = document.querySelector(`label[for="${this.$root.id}"]`)

if (!$label) {
throw new ElementError({
component: FileUpload,
identifier: 'No label'
})
}

return $label
}

/**
* When the button is clicked, emulate clicking the actual, hidden file input
*/
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) {
// 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.
*/
observeDisabledState() {
const observer = new MutationObserver((mutationList) => {
for (const mutation of mutationList) {
console.log('mutation', mutation)
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'disabled'
) {
this.updateDisabledState()
}
}
})

observer.observe(this.$root, {
attributes: true
})
}

/**
* Synchronise the `disabled` state between the input and replacement button.
*/
updateDisabledState() {
this.$button.disabled = this.$root.disabled
}

/**
* 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: {
// the 'one' string isn't used as the component displays the filename
// instead, however it's here for coverage's sake
one: '%{count} file chosen',
other: '%{count} files chosen'
}
}
})

/**
* File upload config schema
*
* @constant
* @satisfies {Schema}
*/
static schema = Object.freeze({
properties: {
i18n: { type: 'object' }
}
})
}

/**
* @typedef {HTMLInputElement & {files: FileList}} HTMLFileInputElement
*/

/**
* 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/configuration.mjs').Schema} Schema
* @typedef {import('../../i18n.mjs').TranslationPluralForms} TranslationPluralForms
*/
Loading
Loading