Skip to content

Commit

Permalink
[WIP] Spike progressively enhanced file upload
Browse files Browse the repository at this point in the history
  • Loading branch information
querkmachine committed Sep 12, 2024
1 parent 5ae4fe1 commit 8fe1f08
Show file tree
Hide file tree
Showing 9 changed files with 321 additions and 8 deletions.
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

0 comments on commit 8fe1f08

Please sign in to comment.