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 1 commit into
base: main
Choose a base branch
from
Draft
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
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 @@ -56,6 +56,7 @@ describe('GOV.UK Frontend', () => {
'Checkboxes',
'ErrorSummary',
'ExitThisPage',
'FileUpload',
'Header',
'NotificationBanner',
'PasswordInput',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
*/
Original file line number Diff line number Diff line change
Expand Up @@ -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: true
- 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
Expand All @@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down
2 changes: 2 additions & 0 deletions packages/govuk-frontend/src/govuk/init.jsdom.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -37,6 +38,7 @@ describe('initAll', () => {
'character-count',
'error-summary',
'exit-this-page',
'file-upload',
'notification-banner',
'password-input'
]
Expand Down
Loading
Loading