From e94d33a14c87af93361c6acef71d8e1fde64a3dd Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Fri, 2 Sep 2022 09:10:21 +1000 Subject: [PATCH 01/13] Files created --- src/components/file-upload/_file-upload.hbs | 0 src/components/file-upload/_file-upload.scss | 0 src/components/file-upload/file-upload.js | 0 src/components/file-upload/file-upload.json | 0 src/components/file-upload/index.hbs | 0 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/components/file-upload/_file-upload.hbs create mode 100644 src/components/file-upload/_file-upload.scss create mode 100644 src/components/file-upload/file-upload.js create mode 100644 src/components/file-upload/file-upload.json create mode 100644 src/components/file-upload/index.hbs diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs new file mode 100644 index 00000000..e69de29b diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js new file mode 100644 index 00000000..e69de29b diff --git a/src/components/file-upload/file-upload.json b/src/components/file-upload/file-upload.json new file mode 100644 index 00000000..e69de29b diff --git a/src/components/file-upload/index.hbs b/src/components/file-upload/index.hbs new file mode 100644 index 00000000..e69de29b From 20aa99ff605a26a35bfcb6e04bf600e41c09af9f Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Mon, 5 Sep 2022 10:15:34 +1000 Subject: [PATCH 02/13] Files created --- src/components/file-upload/_guidance.hbs | 36 +++++++++++++++++++ src/components/file-upload/file-upload.js | 15 ++++++++ src/components/file-upload/index.hbs | 13 +++++++ .../file-upload/{ => json}/file-upload.json | 0 4 files changed, 64 insertions(+) create mode 100644 src/components/file-upload/_guidance.hbs rename src/components/file-upload/{ => json}/file-upload.json (100%) diff --git a/src/components/file-upload/_guidance.hbs b/src/components/file-upload/_guidance.hbs new file mode 100644 index 00000000..51f521f3 --- /dev/null +++ b/src/components/file-upload/_guidance.hbs @@ -0,0 +1,36 @@ +--- +title: File upload +layout: blank-layout.hbs +--- + +

Usage

+

Use file upload when a user is required to upload a file from their device to a specific location. Only ask users to upload files when it's critical to the delivery of your service.

+ +

Do:

+ + + +

When to avoid

+

Avoid asking users to provide documents if you don't require them. Be considerate of file size and don't ask users to upload large files.

+ +

How this component works

+ +

Truncate file names

+

Truncate file names when they extend beyond the width of the parent element

+ +

Upload status

+

Where a user would benefit from knowing the status of their upload consider adding indicators.

+ +

Multiple uploads

+

Clearly communicate if a user can upload multiple files and consider displaying additional files vertically.

+ +

Error messages

+

Error messages inform the user when there is an issue with the data they have input. Provide helpful error messages to clearly explain to the user what the issue is and how they can effectively address the errors. Check out GOV.UK Design System's error messages for file uploads for some best practice examples.

+ +

Accessibility

+

All components are responsive and meet WCAG 2.1 AA accessibility guidelines.

diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index e69de29b..564cf066 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -0,0 +1,15 @@ +class FileUpload { + constructor(element) { + this.fileUpload = element + } + + init() { + this.controls() + } + + controls() { + this.controls() + } +} + +export default FileUpload diff --git a/src/components/file-upload/index.hbs b/src/components/file-upload/index.hbs index e69de29b..7d19ffb1 100644 --- a/src/components/file-upload/index.hbs +++ b/src/components/file-upload/index.hbs @@ -0,0 +1,13 @@ +--- +title: File upload +width: narrow +tabs: true +directory: file-upload +intro: File uploaders help users to select and upload files. +model: + file-upload: ../../components/file-upload/json/file-upload.json +meta-description: File uploaders help users to select and upload files. +meta-index: true +--- + +{{#>_docs-example}}{{/_docs-example}} \ No newline at end of file diff --git a/src/components/file-upload/file-upload.json b/src/components/file-upload/json/file-upload.json similarity index 100% rename from src/components/file-upload/file-upload.json rename to src/components/file-upload/json/file-upload.json From 8b6fe9eb14be9a87ad9f3803d028ca26373bcf50 Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Tue, 6 Sep 2022 09:53:56 +1000 Subject: [PATCH 03/13] Update file-upload.js --- src/components/file-upload/file-upload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 564cf066..92a22e11 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -1,6 +1,6 @@ class FileUpload { constructor(element) { - this.fileUpload = element + this.element = element } init() { From 2709787a542da71f1702039b02777fe0d9d8cbcb Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Tue, 13 Sep 2022 14:21:16 +1000 Subject: [PATCH 04/13] File setup and initial code for file-upload --- src/components/_all.scss | 1 + src/components/file-upload/_file-upload.hbs | 7 +++ src/components/file-upload/_file-upload.scss | 13 ++++++ src/components/file-upload/blank.hbs | 9 ++++ src/components/file-upload/file-upload.js | 45 +++++++++++++++++-- src/components/file-upload/index.hbs | 4 +- .../file-upload/json/file-upload.json | 3 ++ src/main.js | 8 ++++ src/main.scss | 1 + 9 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 src/components/file-upload/blank.hbs diff --git a/src/components/_all.scss b/src/components/_all.scss index 11ff9506..e17b2f32 100644 --- a/src/components/_all.scss +++ b/src/components/_all.scss @@ -5,6 +5,7 @@ @import 'callout/callout'; @import 'card/card'; @import 'content-block/content-block'; +@import 'file-upload/file-upload'; @import 'filters/filters'; @import 'footer/footer'; @import 'form/form'; diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs index e69de29b..23a82dd9 100644 --- a/src/components/file-upload/_file-upload.hbs +++ b/src/components/file-upload/_file-upload.hbs @@ -0,0 +1,7 @@ +
+ + + +
\ No newline at end of file diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index e69de29b..4f94fe9c 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -0,0 +1,13 @@ +.nsw-file-upload { + &__label { + display: initial; + } + + &__text { + display: initial; + } + + &__input { + display: none; + } +} diff --git a/src/components/file-upload/blank.hbs b/src/components/file-upload/blank.hbs new file mode 100644 index 00000000..4af420f7 --- /dev/null +++ b/src/components/file-upload/blank.hbs @@ -0,0 +1,9 @@ +--- +title: File upload +width: narrow +model: + file-upload: ../../components/file-upload/json/file-upload.json +page: true +--- + +{{#>_layout-container}}{{>_file-upload model.file-upload}}{{/_layout-container}} diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 92a22e11..3d1739ef 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -1,14 +1,53 @@ +/* eslint-disable max-len */ class FileUpload { constructor(element) { this.element = element + this.input = this.element.querySelector('.nsw-file-upload__input') + this.label = this.element.querySelector('.nsw-file-upload__label') + this.multipleUpload = this.input.hasAttribute('multiple') // allow for multiple files selection + + // when user selects a file, it will be changed from the default value to the name of the file + this.labelText = this.element.querySelector('.nsw-file-upload__text') + this.initialLabel = this.labelText.textContent } init() { - this.controls() + this.initInputFileEvents() + } + + initInputFileEvents() { + // make label focusable + this.label.setAttribute('tabindex', '0') + this.input.setAttribute('tabindex', '-1') + + // move focus from input to label -> this is triggered when a file is selected or the file picker modal is closed + this.input.addEventListener('focusin', (event) => { + console.log(event) + this.label.focus() + }) + + // press 'Enter' key on label element -> trigger file selection + this.label.addEventListener('keydown', (event) => { + if ((event.keyCode && event.keyCode === 13) || (event.key && event.key.toLowerCase() === 'enter')) { this.input.click() } + }) + + // file has been selected -> update label text + this.input.addEventListener('change', (event) => { + console.log(event) + this.updateInputLabelText() + }) } - controls() { - this.controls() + updateInputLabelText() { + let label = '' + if (this.input.files && this.input.files.length < 1) { + label = this.initialLabel // no selection -> revert to initial label + } else if (this.multipleUpload && this.input.files && this.input.files.length > 1) { + label = `${this.input.files.length} files` // multiple selection -> show number of files + } else { + label = this.input.value.split('\\').pop() // single file selection -> show name of the file + } + this.labelText.textContent = label } } diff --git a/src/components/file-upload/index.hbs b/src/components/file-upload/index.hbs index 7d19ffb1..58125452 100644 --- a/src/components/file-upload/index.hbs +++ b/src/components/file-upload/index.hbs @@ -10,4 +10,6 @@ meta-description: File uploaders help users to select and upload files. meta-index: true --- -{{#>_docs-example}}{{/_docs-example}} \ No newline at end of file +{{#>_docs-example}} +{{>_file-upload model.file-upload}} +{{/_docs-example}} \ No newline at end of file diff --git a/src/components/file-upload/json/file-upload.json b/src/components/file-upload/json/file-upload.json index e69de29b..fade19e1 100644 --- a/src/components/file-upload/json/file-upload.json +++ b/src/components/file-upload/json/file-upload.json @@ -0,0 +1,3 @@ +{ + "id": "nsw-file-upload" +} diff --git a/src/main.js b/src/main.js index 3bcce3e2..8de0c3cd 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,7 @@ import Navigation from './components/main-nav/main-nav' import Accordion from './components/accordion/accordion' import Dialog from './components/dialog/dialog' import Filters from './components/filters/filters' +import FileUpload from './components/file-upload/file-upload' import Tabs from './components/tabs/tabs' import GlobalAlert from './components/global-alert/global-alert' @@ -32,6 +33,7 @@ function initSite() { const closeSearchButton = document.querySelectorAll('.js-close-search') const accordions = document.querySelectorAll('.js-accordion') const dialogs = document.querySelectorAll('.js-dialog') + const fileUpload = document.querySelectorAll('.js-file-upload') const filters = document.querySelectorAll('.js-filters') const tabs = document.querySelectorAll('.js-tabs') const globalAlert = document.querySelectorAll('.js-global-alert') @@ -55,6 +57,12 @@ function initSite() { new Dialog(element).init() }) + if (fileUpload) { + fileUpload.forEach((element) => { + new FileUpload(element).init() + }) + } + if (filters) { filters.forEach((element) => { new Filters(element).init() diff --git a/src/main.scss b/src/main.scss index a6718d0d..0c9f9a6f 100644 --- a/src/main.scss +++ b/src/main.scss @@ -14,6 +14,7 @@ @import 'components/callout/callout'; @import 'components/card/card'; @import 'components/content-block/content-block'; +@import 'components/file-upload/file-upload'; @import 'components/filters/filters'; @import 'components/footer/footer'; @import 'components/form/form'; From 648cf9ff346f9d4651e306c03373656eacec23ff Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Wed, 14 Sep 2022 09:29:49 +1000 Subject: [PATCH 05/13] JavaScript and sass code improvements --- src/components/file-upload/_file-upload.hbs | 22 +- src/components/file-upload/_file-upload.scss | 20 ++ src/components/file-upload/file-upload.js | 240 ++++++++++++++++++- 3 files changed, 272 insertions(+), 10 deletions(-) diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs index 23a82dd9..635a9368 100644 --- a/src/components/file-upload/_file-upload.hbs +++ b/src/components/file-upload/_file-upload.hbs @@ -1,7 +1,21 @@ -
+
- -
\ No newline at end of file + + + +
+ + + diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 4f94fe9c..1f589620 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -10,4 +10,24 @@ &__input { display: none; } + + &__list { + display: initial; + } + + &__item { + display: initial; + } + + &__file-name { + display: initial; + } + + &__remove-btn { + display: initial; + } + + .hidden { + display: none; + } } diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 3d1739ef..248a1b4d 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -4,15 +4,214 @@ class FileUpload { this.element = element this.input = this.element.querySelector('.nsw-file-upload__input') this.label = this.element.querySelector('.nsw-file-upload__label') + this.accept = this.input.getAttribute('accept') this.multipleUpload = this.input.hasAttribute('multiple') // allow for multiple files selection - // when user selects a file, it will be changed from the default value to the name of the file this.labelText = this.element.querySelector('.nsw-file-upload__text') this.initialLabel = this.labelText.textContent + // + this.uploadedFiles = [] + this.lastUploadedFiles = [] + this.acceptFile = [] + // + this.filesList = false + this.fileItems = false + // options + this.showFiles = true + this.replaceFiles = this.element.hasAttribute('data-replace-files') + this.maxSize = '' + this.maxFiles = '' } init() { this.initInputFileEvents() + this.initshowFiles() + this.initFileAccept() + this.initFileInput() + } + + initshowFiles() { + this.filesList = this.element.querySelector('.js-file-upload__list') + if (this.filesList.length === 0) return + this.fileItems = this.filesList.querySelectorAll('.js-file-upload__item') + + if (this.fileItems.length > 0) this.constructor.addClass(this.fileItems[0], 'hidden') + // listen for click on remove file action + this.initRemoveFile() + } + + initFileAccept() { + if (!this.accept) return + if (this.input) { + // store accepted file format + this.acceptFile = this.accept.split(',').map((element) => element.trim()) + } + } + + initFileInput() { + // listen to changes in the input file element + if (!this.input) return + this.input.addEventListener('change', () => { + if (this.input.value === '') return + this.storeUploadedFiles(this.input.files) + this.input.value = '' + this.updateFileInput() + }) + } + + storeUploadedFiles(fileData) { + // check files size/format/number + this.lastUploadedFiles = [] + if (this.replaceFiles) this.uploadedFiles = [] + Array.prototype.push.apply(this.lastUploadedFiles, fileData) + this.filterUploadedFiles() // remove files that do not respect format/size + this.uploadedFiles = this.uploadedFiles.concat(this.lastUploadedFiles) + if (this.maxFiles) this.filterMaxFiles() // check max number of files + } + + updateFileInput() { + // update UI + emit events + this.updateFileList() + this.emitCustomEvents('filesUploaded', false) + } + + filterUploadedFiles() { + // check max weight + if (this.maxSize) this.filterMaxSize() + // check file format + if (this.acceptFile.length > 0) this.filterAcceptFile() + } + + filterMaxSize() { + // filter files by size + const rejected = [] + for (let i = this.lastUploadedFiles.length - 1; i >= 0; i -= 1) { + if (this.lastUploadedFiles[i].size > this.maxSize * 1000) { + const rejectedFile = this.lastUploadedFiles.splice(i, 1) + rejected.push(rejectedFile[0].name) + } + } + if (rejected.length > 0) { + this.emitCustomEvents('rejectedWeight', rejected) + } + } + + filterAcceptFile() { + // filter files by format + const rejected = [] + for (let i = this.lastUploadedFiles.length - 1; i >= 0; i -= 1) { + if (!this.formatInList(i)) { + const rejectedFile = this.lastUploadedFiles.splice(i, 1) + rejected.push(rejectedFile[0].name) + } + } + + if (rejected.length > 0) { + this.emitCustomEvents('rejectedFormat', rejected) + } + } + + formatInList(index) { + const { name, type } = this.lastUploadedFiles[index] + const typeStr = type.split('/') + const typeSpec = `${typeStr[0]}/*` + const lastDot = name.lastIndexOf('.') + const extension = name.substring(lastDot + 1) + + let accepted = false + for (let i = 0; i < this.acceptFile.length; i += 1) { + const file = this.acceptFile[i] + + if ((type === file) || (typeSpec === file || (extension && extension === file))) { + accepted = true + break + } + + if (extension && this.constructor.extensionInList(extension, file)) { + // extension could be list of format; e.g. for the svg it is svg+xml + accepted = true + break + } + } + return accepted + } + + static extensionInList(extensionList, extension) { + // extension could be .svg, .pdf, .. + // extensionList could be png, svg+xml, ... + if (`.${extensionList}` === extension) return true + let accepted = false + const extensionListArray = extensionList.split('+') + for (let i = 0; i < extensionListArray.length; i += 1) { + if (`.${extensionListArray[i]}` === extension) { + accepted = true + break + } + } + return accepted + } + + filterMaxFiles() { + // check number of uploaded files + if (this.maxFiles >= this.uploadedFiles.length) return + const rejected = [] + while (this.uploadedFiles.length > this.maxFiles) { + const rejectedFile = this.uploadedFiles.pop() + this.lastUploadedFiles.pop() + rejected.push(rejectedFile.name) + } + + if (rejected.length > 0) { + this.emitCustomEvents('rejectedNumber', rejected) + } + } + + updateFileList() { + // create new list of files to be appended + if (!this.fileItems || this.fileItems.length === 0) return + const clone = this.fileItems[0].cloneNode(true) + let string = '' + this.constructor.removeClass(clone, 'hidden') + for (let i = 0; i < this.lastUploadedFiles.length; i += 1) { + clone.querySelectorAll('.js-file-upload__file-name')[0].textContent = this.lastUploadedFiles[i].name + string = clone.outerHTML + string + } + + if (this.replaceFiles) { + // replace all files in list with new files + string = this.fileItems[0].outerHTML + string + this.filesList.innerHTML = string + } else { + this.fileItems[0].insertAdjacentHTML('afterend', string) + } + + this.constructor.toggleClass(this.filesList, 'hidden', this.uploadedFiles.length === 0) + } + + initRemoveFile() { + // if list of files is visible - option to remove file from list + this.filesList.addEventListener('click', (event) => { + if (!event.target.closest('.js-file-upload__remove-btn')) return + event.preventDefault() + const item = event.target.closest('.js-file-upload__item') + const index = this.constructor.getIndexInArray(this.filesList.querySelectorAll('.js-file-upload__item'), item) + + const removedFile = this.uploadedFiles.splice(this.uploadedFiles.length - index, 1) + + // check if we need to remove items form the lastUploadedFiles array + const lastUploadedIndex = this.lastUploadedFiles.length - index + if (lastUploadedIndex >= 0 && lastUploadedIndex < this.lastUploadedFiles.length - 1) { + this.lastUploadedFiles.splice(this.lastUploadedFiles.length - index, 1) + } + item.remove() + this.emitCustomEvents('fileRemoved', removedFile) + this.updateInputLabelText() + }) + } + + emitCustomEvents(eventName, detail) { + const event = new CustomEvent(eventName, { detail }) + this.element.dispatchEvent(event) } initInputFileEvents() { @@ -21,8 +220,7 @@ class FileUpload { this.input.setAttribute('tabindex', '-1') // move focus from input to label -> this is triggered when a file is selected or the file picker modal is closed - this.input.addEventListener('focusin', (event) => { - console.log(event) + this.input.addEventListener('focusin', () => { this.label.focus() }) @@ -31,9 +229,8 @@ class FileUpload { if ((event.keyCode && event.keyCode === 13) || (event.key && event.key.toLowerCase() === 'enter')) { this.input.click() } }) - // file has been selected -> update label text - this.input.addEventListener('change', (event) => { - console.log(event) + // file(s) has been selected -> update label text + this.input.addEventListener('change', () => { this.updateInputLabelText() }) } @@ -49,6 +246,37 @@ class FileUpload { } this.labelText.textContent = label } + // End Original Function + + static getIndexInArray(array, el) { + return Array.prototype.indexOf.call(array, el) + } + + static hasClass(el, className) { + return el.classList.contains(className) + } + + static addClass(el, className) { + el.classList.add(className) + } + + static removeClass(el, className) { + el.classList.remove(className) + } + + static toggleClass(el, className, bool) { + if (bool) this.addClass(el, className) + else this.removeClass(el, className) + } + + static setAttributes(el, attrs) { + Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value)) + } + + static preventDefaults(event) { + event.preventDefault() + event.stopPropagation() + } } export default FileUpload From a2d7dcf097e24f70c65ff930c9fb3066fcf0a59c Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Wed, 14 Sep 2022 17:25:43 +1000 Subject: [PATCH 06/13] file-upload javascript and css updates --- src/components/file-upload/_file-upload.hbs | 15 +- src/components/file-upload/_file-upload.scss | 54 ++++++- src/components/file-upload/file-upload.js | 144 +++++++++++-------- 3 files changed, 150 insertions(+), 63 deletions(-) diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs index 635a9368..dc3c7837 100644 --- a/src/components/file-upload/_file-upload.hbs +++ b/src/components/file-upload/_file-upload.hbs @@ -1,20 +1,29 @@ -
+
+ + + - + + +
diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 1f589620..1009f144 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -1,4 +1,10 @@ .nsw-file-upload { + display: block; + + &__helper { + @include nsw-form__helper; + } + &__label { display: initial; } @@ -12,19 +18,59 @@ } &__list { - display: initial; + margin: 0; + padding: 0; + border: 0; } &__item { - display: initial; + max-width: rem(500px); + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--nsw-off-white); + border-radius: var(--nsw-border-radius); + box-shadow: var(--nsw-box-shadow); + padding: rem(16px); + + &:not(:last-child) { + margin-bottom: rem(16px); + } } &__file-name { - display: initial; + margin-left: rem(16px); + + &.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } &__remove-btn { - display: initial; + font-size: rem(map-get($nsw-icon-sizes, 20)); + background-color: var(--nsw-white); + color: var(--nsw-brand-dark); + border-radius: 50%; + width: rem(16px); + height: rem(16px); + display: flex; + justify-content: center; + align-items: center; + margin-left: auto; + flex-shrink: 0; + border: 0; + + &:hover { + cursor: pointer; + outline: none; + color: var(--nsw-brand-accent); + } + + &:focus { + @include nsw-focus; + } } .hidden { diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 248a1b4d..5251cce7 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -4,30 +4,27 @@ class FileUpload { this.element = element this.input = this.element.querySelector('.nsw-file-upload__input') this.label = this.element.querySelector('.nsw-file-upload__label') - this.accept = this.input.getAttribute('accept') - this.multipleUpload = this.input.hasAttribute('multiple') // allow for multiple files selection - // when user selects a file, it will be changed from the default value to the name of the file this.labelText = this.element.querySelector('.nsw-file-upload__text') + this.errorMessage = this.element.querySelector('.nsw-file-upload__helper') this.initialLabel = this.labelText.textContent - // + this.filesList = false + this.fileItems = false this.uploadedFiles = [] this.lastUploadedFiles = [] this.acceptFile = [] - // - this.filesList = false - this.fileItems = false - // options - this.showFiles = true + // file-upload options + this.multipleUpload = this.input.hasAttribute('multiple') // allow for multiple files selection + this.accept = this.input.hasAttribute('accept') ? this.input.getAttribute('accept') : null this.replaceFiles = this.element.hasAttribute('data-replace-files') - this.maxSize = '' - this.maxFiles = '' + this.maxFiles = this.element.hasAttribute('data-max-files') ? this.element.getAttribute('data-max-files') : null + this.maxSize = this.element.hasAttribute('data-max-file-size') ? this.element.getAttribute('data-max-file-size') : null } init() { - this.initInputFileEvents() this.initshowFiles() this.initFileAccept() this.initFileInput() + this.customEvents() } initshowFiles() { @@ -49,12 +46,27 @@ class FileUpload { } initFileInput() { - // listen to changes in the input file element if (!this.input) return + + // make label focusable + this.label.setAttribute('tabindex', '0') + this.input.setAttribute('tabindex', '-1') + + // move focus from input to label -> this is triggered when a file is selected or the file picker modal is closed + this.input.addEventListener('focusin', () => { + this.label.focus() + }) + + // press 'Enter' key on label element -> trigger file selection + this.label.addEventListener('keydown', (event) => { + if ((event.keyCode && event.keyCode === 13) || (event.key && event.key.toLowerCase() === 'enter')) { this.input.click() } + }) + + // listen to changes in the input file element this.input.addEventListener('change', () => { if (this.input.value === '') return this.storeUploadedFiles(this.input.files) - this.input.value = '' + // this.input.value = '' this.updateFileInput() }) } @@ -67,6 +79,7 @@ class FileUpload { this.filterUploadedFiles() // remove files that do not respect format/size this.uploadedFiles = this.uploadedFiles.concat(this.lastUploadedFiles) if (this.maxFiles) this.filterMaxFiles() // check max number of files + this.updateInputLabelText(this.uploadedFiles) } updateFileInput() { @@ -75,6 +88,20 @@ class FileUpload { this.emitCustomEvents('filesUploaded', false) } + updateInputLabelText(uploadedFiles) { + // when user selects a file, it will be changed from the default value to the name of the file or number of files + let label = '' + if (uploadedFiles && uploadedFiles.length < 1) { + label = this.initialLabel // no selection -> revert to initial label + } else if (this.multipleUpload && uploadedFiles && uploadedFiles.length > 1) { + label = `${uploadedFiles.length} files` // multiple selection -> show number of files + } else { + const singleFile = this.input.value.split('\\').pop() + label = this.constructor.truncateString(singleFile, 35) // single file selection -> show name of the file + } + this.labelText.textContent = label + } + filterUploadedFiles() { // check max weight if (this.maxSize) this.filterMaxSize() @@ -92,7 +119,7 @@ class FileUpload { } } if (rejected.length > 0) { - this.emitCustomEvents('rejectedWeight', rejected) + this.emitCustomEvents('rejectedSize', rejected) } } @@ -136,21 +163,6 @@ class FileUpload { return accepted } - static extensionInList(extensionList, extension) { - // extension could be .svg, .pdf, .. - // extensionList could be png, svg+xml, ... - if (`.${extensionList}` === extension) return true - let accepted = false - const extensionListArray = extensionList.split('+') - for (let i = 0; i < extensionListArray.length; i += 1) { - if (`.${extensionListArray[i]}` === extension) { - accepted = true - break - } - } - return accepted - } - filterMaxFiles() { // check number of uploaded files if (this.maxFiles >= this.uploadedFiles.length) return @@ -173,7 +185,8 @@ class FileUpload { let string = '' this.constructor.removeClass(clone, 'hidden') for (let i = 0; i < this.lastUploadedFiles.length; i += 1) { - clone.querySelectorAll('.js-file-upload__file-name')[0].textContent = this.lastUploadedFiles[i].name + const { name } = this.lastUploadedFiles[i] + clone.querySelectorAll('.js-file-upload__file-name')[0].textContent = this.constructor.truncateString(name, 50) string = clone.outerHTML + string } @@ -184,7 +197,7 @@ class FileUpload { } else { this.fileItems[0].insertAdjacentHTML('afterend', string) } - + this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) this.constructor.toggleClass(this.filesList, 'hidden', this.uploadedFiles.length === 0) } @@ -204,8 +217,8 @@ class FileUpload { this.lastUploadedFiles.splice(this.lastUploadedFiles.length - index, 1) } item.remove() + this.updateInputLabelText(this.uploadedFiles) this.emitCustomEvents('fileRemoved', removedFile) - this.updateInputLabelText() }) } @@ -214,39 +227,51 @@ class FileUpload { this.element.dispatchEvent(event) } - initInputFileEvents() { - // make label focusable - this.label.setAttribute('tabindex', '0') - this.input.setAttribute('tabindex', '-1') + customEvents() { + this.element.addEventListener('filesUploaded', () => { + // new files have been selected + // this.uploadedFiles -> gives you the list of all selected files + // console.log(this.uploadedFiles) + // this.lastUploadedFiles -> gives you the list of the last selected files. It may be different from this.uploadedFiles if replaceFiles option is false + // console.log(this.lastUploadedFiles) + }) - // move focus from input to label -> this is triggered when a file is selected or the file picker modal is closed - this.input.addEventListener('focusin', () => { - this.label.focus() + this.element.addEventListener('rejectedSize', (event) => { + // event.detail gives you the list of the rejected files + console.log(`rejectedSize: ${event.detail}`) + this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) }) - // press 'Enter' key on label element -> trigger file selection - this.label.addEventListener('keydown', (event) => { - if ((event.keyCode && event.keyCode === 13) || (event.key && event.key.toLowerCase() === 'enter')) { this.input.click() } + this.element.addEventListener('rejectedFormat', (event) => { + // event.detail gives you the list of the rejected files + console.log(`rejectedFormat: ${event.detail}`) }) - // file(s) has been selected -> update label text - this.input.addEventListener('change', () => { - this.updateInputLabelText() + this.element.addEventListener('rejectedNumber', (event) => { + // event.detail gives you the list of the rejected files + console.log(`rejectedNumber: ${event.detail}`) + }) + + this.element.addEventListener('fileRemoved', (event) => { + // event.detail gives you the removed file + console.log(`fileRemoved: ${event.detail}`) }) } - updateInputLabelText() { - let label = '' - if (this.input.files && this.input.files.length < 1) { - label = this.initialLabel // no selection -> revert to initial label - } else if (this.multipleUpload && this.input.files && this.input.files.length > 1) { - label = `${this.input.files.length} files` // multiple selection -> show number of files - } else { - label = this.input.value.split('\\').pop() // single file selection -> show name of the file + static extensionInList(extensionList, extension) { + // extension could be .svg, .pdf, .. + // extensionList could be png, svg+xml, ... + if (`.${extensionList}` === extension) return true + let accepted = false + const extensionListArray = extensionList.split('+') + for (let i = 0; i < extensionListArray.length; i += 1) { + if (`.${extensionListArray[i]}` === extension) { + accepted = true + break + } } - this.labelText.textContent = label + return accepted } - // End Original Function static getIndexInArray(array, el) { return Array.prototype.indexOf.call(array, el) @@ -277,6 +302,13 @@ class FileUpload { event.preventDefault() event.stopPropagation() } + + static truncateString(str, num) { + if (str.length <= num) { + return str + } + return `${str.slice(0, num)}...` + } } export default FileUpload From a1371aa8211993f6428bcc59ac39421c05028b9d Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Mon, 5 Dec 2022 10:34:53 +1100 Subject: [PATCH 07/13] Error messages styling and functionality --- src/components/file-upload/_file-upload.scss | 40 +++++++++++++++++++- src/components/file-upload/file-upload.js | 6 +++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 1009f144..904e521a 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -2,11 +2,47 @@ display: block; &__helper { - @include nsw-form__helper; + @include font-size('xs'); + display: block; + margin-bottom: rem(4px); + + &--error, + &--valid { + margin-top: rem(8px); + padding: rem(8px); + font-weight: var(--nsw-font-bold); + color: var(--nsw-text-dark); + background-repeat: no-repeat; + background-position: left rem(8px) top rem(8px); + background-size: 1rem auto; + display: flex; + align-items: center; + + .nsw-material-icons { + font-size: rem(map-get($nsw-icon-sizes, 20)); + margin-right: rem(4px); + } + } + + &--error { + background-color: var(--nsw-status-error-bg); + + .nsw-material-icons { + color: var(--nsw-status-error); + } + } + + &--valid { + background-color: var(--nsw-status-success-bg); + + .nsw-material-icons { + color: var(--nsw-status-success); + } + } } &__label { - display: initial; + margin: 0; } &__text { diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 5251cce7..70aadbc9 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -239,17 +239,23 @@ class FileUpload { this.element.addEventListener('rejectedSize', (event) => { // event.detail gives you the list of the rejected files console.log(`rejectedSize: ${event.detail}`) + + this.errorMessage.innerText = `The selected file must be smaller than ${this.maxSize} KB` this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) }) this.element.addEventListener('rejectedFormat', (event) => { // event.detail gives you the list of the rejected files console.log(`rejectedFormat: ${event.detail}`) + this.errorMessage.innerText = `The selected file must be a ${this.accept}` + this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) }) this.element.addEventListener('rejectedNumber', (event) => { // event.detail gives you the list of the rejected files console.log(`rejectedNumber: ${event.detail}`) + this.errorMessage.innerText = `You can only select up to ${this.maxFiles} files at the same time` + this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) }) this.element.addEventListener('fileRemoved', (event) => { From 62ae2b96b5b606da44f84d67f053f878c4195a80 Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Tue, 7 Mar 2023 17:53:11 +1100 Subject: [PATCH 08/13] Removed validation functionality, added images to guidance, fixed bugs and removed unnecessary styles --- src/assets/images/file-upload-complete.png | Bin 0 -> 10764 bytes src/assets/images/file-upload-multiple.png | Bin 0 -> 38227 bytes src/assets/images/file-upload-progress.png | Bin 0 -> 12049 bytes src/components/file-upload/_file-upload.hbs | 32 ++- src/components/file-upload/_file-upload.scss | 89 +------- src/components/file-upload/_guidance.hbs | 38 ++++ src/components/file-upload/blank.hbs | 27 ++- src/components/file-upload/file-upload.js | 213 ++---------------- src/components/file-upload/index.hbs | 28 ++- .../file-upload/json/file-upload.json | 3 - src/main.js | 2 +- 11 files changed, 132 insertions(+), 300 deletions(-) create mode 100644 src/assets/images/file-upload-complete.png create mode 100644 src/assets/images/file-upload-multiple.png create mode 100644 src/assets/images/file-upload-progress.png delete mode 100644 src/components/file-upload/json/file-upload.json diff --git a/src/assets/images/file-upload-complete.png b/src/assets/images/file-upload-complete.png new file mode 100644 index 0000000000000000000000000000000000000000..53d3826e8069e7840971875aebc865437b5d70dd GIT binary patch literal 10764 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 ad|~jD+{!B-!K^0?cI&3GPNmk}zyAa2wezX~ literal 0 HcmV?d00001 diff --git a/src/assets/images/file-upload-multiple.png b/src/assets/images/file-upload-multiple.png new file mode 100644 index 0000000000000000000000000000000000000000..ea3f217d87a62c9f5419e70f897310c74cd43933 GIT binary patch literal 38227 zcmeFZXH=72*FP9LAP9)`B8q_0i}a>+1*xGEiu8^^=tV_9rAZ4-Ku|gXDFF#hMQNc( z3!SKR0)&qAIk~-`_y2yGS@Ut$nuoPqiIj`$>}#KW_Wl)K>1e4@kTH=#AP|ZN>dJZ$ z$OR<`*vZe!o!}Keu)00zMfwAD?MR zl>Ys0|FhP&|9%pp%}##d@7tfGFLM9=r0g@HYk%K9<)(@}`}gho`Tt(@ZzTRd6w_aY zI|BFWeP9)S)c-y^?e)Xs9RWoXN5=m?cVbBXoA&>&cRewg{>>sCryTB_4hOtt*Fq=v zrojR8KHr92KQ0WpW3-9+**7=5?i=7XQZYNczRQ$y4Yi%L(3hi7;di4e7a!OZ{r4hI zFH)HW@KvUSsk_cq2B+@lmJtFw_ns?r$lPMuj|DG0(693gdxv<~i>m)rnRiZdgDt6e zZx^qi4UbLKuA2d3Bl>gStWIB8M?$l2z}V?y14KU`i4s2M-xBpX^W^SMv3WzasV}ci zgT-bOyBei$$Mr~yiq1_YYHPVFHEKgRO+Y4WcvZvz)n(=NfY3Q3(?A)6DmmLqoi zJIV~E4*w~C)9ae27AKNB#gl1frGC1~%ClL`zWapS5xHS_wopy)f68cWbHxBo9oMOV znXyGrt8`X#uq4-0o@j(J9tAW->(RW(g{%Hsd_ST>vV(8Jlt!Gz#f$U>_buKW>Z^eu z?grB5FK5V+AJ+xa{~P(+ANs>}s1pbx3hE<2#P((PKTNCK|DV$AsNO^Mf{8>q^W1+b zI8(3Dl1By?i5SDPMeuGJcJ11KO8(QxO|X&!t&{rV=Q^!^>DNCHW^?A!PePia&qm~6 z8z7FMsRG&^^qsTwI7`x~xuI7H(g>2zhII*=o!>D=TcN%O--Mk=qn4q02O^eHD$4fm z78UwVKh!0~A&`632Qx#36cR3tH1SDJ`{4cN$*WTXLf+>WLN#Z|S>zM<_N;{1XBJdp zAx5Tyw%bSkD$N=O;s!tVRYdM=eB&6wKp=%ausU-;P`<`D=^kr%c|5s8aEQIujNO#h z{Dd|Cq;?MS)P6hlkHTErq-j&MZY{I7%hlK;)zFDud?5V=DH0{-r#M1t%5YfLEV!an z$Whv#WBGO*icNb~ZaaAkhT(NVTq=@FxYCY02k8;W!a6fGi;H{}1F?oqtnb$c(&s*o z?Ca}1h56vrDN@uJ#v#6SsQHN8n>R6~r31{XzDF=h!!i%8$;`oT=@8? zdVzQ=V+#%^#E;ETF&(^>g%v+t%eQH=z?b70$2y#>8OSf*Vjvl^9fRly2;xBZP*kAs zD1^t-1&2OZiSyL)eNl(W8f|@zShDh|lhu-cGVFHhvTC+J2;vx(JHl(q^@(^l(j2Q^ zOI^y-eQa$sw3mgZLPx@fhZ|ciot+>VWf}z!Ny5wpgrzL2q`3&S9 zN)~KA7e7Od)4Z?X4%cw9Jc5Uo*Qkq)*#m{pW?X%HoubD;|l=6ga^Z|+%{P4^*a`DHas;<;}nbsC4R%{L5WaAoY?B{wZNVMk&U&_76@y%yK@va1M0~KY`<<-%j zsv7NIypQ>DG1nPhyu4|lS&0RS)SHoB+FU9+u(l|pBhzz@M~ugy)VV^a?rcR$AWHqh z(}(MSFZ>cI>nmos7`MLre5!A%mdfBlZe;4QU{~C_Z#!-uwTt{_$R=~b!V@o4bKggG z{QCkXXXE%;I7Afbl&DGTp8GDtxR_z5t=aQVwOTQWg-GK8aLCpR{Q&h5zhzHa(_=Ba8Mh6MuQsd#IcltQLl%J|}Sa$Q^z zOB=Zxd(h2Q1YJ6Q;{|eUX&#Xi^nS`qur_%l0<-2D2s@SNi9rg39c6#o1?S`9VAz2`qUj5XbGiGR`~d}4;&IrG$h zFJSXk>8m6!#v69ILe)bIdBax}RjO$25KDRmdeJ_<>jnr^L>mR1#%j&kC50e@=4)H? zI9d6!T+(C(7i$R$W&hX%w|g`qDk3GuPibhBGIRCf*$G>1wW#gulY~G|NupMB<5B{9x_9CC^0 zWc`59a9X|C(oPef+#-6kd$03>m5&_9O+NyG8m!TIq$I$jSgKpMG&E*cWzf(#tRN%Dw zs8ebRI=}u-lr=Wt4ba+EZm{*Jk@0BABnZ19Wnd}qp^U4O?aGr=7&75Fm0@_R&MTLY z;=}xx4I!`<*=kI16R!`lbT_2+PQzaM!=dWe0=$J=!q{u;rJpi47Pm%{n=6hdrk0ko% zt@P~VvOmgx1Dls0HnCa*(QwI_`!%`Bw+Uh%@P6-6dXtRh`ZLSH?5>rmfTiW*EKRLP zG8SzslZ`7fe-I!wQiGpr7U+5Tt~>{yFdRb;2*9%6eEIcW<@(Cq)nd)*j0A~5oNI=# z+{g^+*vM&6y^meq><8(3d?Ax*E-{2-e(U^FGQM97>y}f7#TZ%Sc{sTHh8mgQ4_4WW zFq*_n`Gp)YgCsb-(#)AuXD_Re8&&RL`*S@Z@zBZ+{-`$&_U2iun)CQWI7fP7$Z<<* zrn-FjIa%FxAzXph0J}*sqT+$?3t$1#h?Nlo@343>9 z&vC|o60AHuE7Pxr<$atn4+(8C3_P@qFQ7@KJrpQ}&AWiw1@(pfx?=4ZE_^91W4C6u zHLiZsR@2wkoiW!*Tf>mWfL2%nHcJoM4e$WoN40~si%5&RAMUBOx6KWtkhW76bwaxg zIHmpStNm7uVnowdTCLt+$^%aqM0(lZenXRsqg^ZO$pc3!ywaopI=TB@h%3*D=I<#cKu0|}l96KDV-3b^G>L#i6hyrC+<`_aH_I@!cdGZw5F^+8*`H39`7OnZ zdt(&JF03H3p|D>pQ?tyP4j-oUUYS~$B%L%LUr?}N6C5Cc#v%&pX7ek79aiZ6|~KlM{V-=}Did$Z@a z)mqcP+Ew7a~7(Z5kIlm@O| ziB|VrsU`lx`XtSKTQBGF0hK4WiqE0+H7&$SKJypNKp!?TeiB+5;M7&;E8xijpgq9#oJ5Mm}1i^eDNLXm%j zdLJ2J{1T4v_r;2P2PZda8wA$eU670Q28$|6{Lgi$@A${xM)4C%YI!X-aWB8W@(BjL zA1p5w@>sc^bufdAUQj#PwaIu*IKw5t4mlg*YGhXGdP_f1%VpCFKD>FDu?rQjs9*Na ziG3^Ew0|m}qrI!zAkj`I-lhwMF~_6^!^p~< z`igSeuf}~?FZM8!rcKJlex;>XJ!m>a)nJdBy52uPQCdd&^;!$aB>@Y=7S}6LidEc& zal6Wce62DJ?VW0V@_c~`ywE*Q^jp1l8`HsLrDwD54U@R0x=*&nn4amZR0Kk&AHNgG&{J;6fYTEzJCx%`=TIsi{a1bbK_A}3 z+WKNmd5YU4?Mmo`K~EeVnG3p?Id2dZ@D(0~x>BS;WYk4FA6HqA?3%7=DW^~8F9s4JY<{Om1QYdc0aAF?i#{ZW2MT2g@S zF;=+uYwjCO^H5wBzc_|z)i6@G+Hal}t}Pz#)WAKUyB(pHKQbk-C0x&@Z5)>~rI20f z&UiSru+{nP5`ms2YX7&Li&3}#?*wpmi7~zxr5fb|Q&tMb^d|*n!J!5Dr-|rlN{IyEn1_#5@CCERJ0j981KLbuh-<#-;8+b975(Mld#fk zVPR6;XvIr=3nvaQ+}jTp@!mq)!D8G-nO710l^cz#1y0&_H)^5%sg{tR@cgs=iY2r! z@`QgpYCew6waA67ecQmz4A61rnM=&%qPfTI z_UnS8QwR?&%1u-*e+PY>s6+)lx!CcE>mOH(c1^DLlM3!dk*>FQKI5+T%9qVmiOMS1 zL-|Xh-4qh8$9H0|Gq_&KFZON1Qeauzr;9@{EGCoX`tx$efL8nj+Rf~E-%s?~#XG*m}S;Ti+~t+%&_es@=u&9w6=lUu87VP;X z8`@_L=}BC9_n-16hP(?^>wk`qwyiQ%p-ET~Hecj?SdQ>4di*Rkz1|?Dqvh;N(_UNG znlizBiE(73n(MVOqmtvr4=E`DFB6<&+Dp+~JA14jcgB=n<9KW3$aEuYc|gch4&VJ~ z(%(_+cE^|+jAnM)@(G4Eb(n|wtM!Yj*nzS?Hy58o-z#sq`#memu6X%8=E8F$@2OUj z?o~dAqsgMjQp>3(CD;sKv(Kf=Me_vSw%(92!k)?U+Hf=dnR^_81!0yucB2%^q>4?? z08CTNqaDE$FrQN@?{Z?2i*T5?5@R8&Ut618vbq?^JyXGd+thG(cQmZSK60d zsI$H$10$~vF6E*O-8)xv`WA{Z8=Orj)9hpj{^{9` z^F`Tq^0j15KKdx*X=ao{9I8a?owODd9a<8zp0fx%yxcGI(%+ETRq6aPG=Z>8x(r1 zpnF3{v@!2=%H!!PpwO~jss)2`0Dk!cs$ekc2RNA!1e`h-GgqVDmDry@&uH9YR?Tc5 z4PwwvmYYt&5$cXsz`+rjuE}AP-|$0P*FEj^_!BQAkKb3GYE?2tmA5<6I&rLv>~n47#~8+1UI}V_;gZ#r*@)_w z>Zf0h0}jpMf)(Gqz);tH*^~h0YNBC??IWz;{95fr?+d1b*{01JFl!&W*Tc{EbFn>T&28kf z_1zP#ywDpBnU{>Vl)Q4Sue@E1sWIbPGd6)GfdRu7G*q2qp3MEKB6g%tvPWH4$vlc@ zcO1b;Ddkc>RbJ7<#Xx@BLNVX9rRL8~r>ux(ixZey-%*{fUoEG;Y5G~1bYw)u@UUKGpl#v&I~TteqIwEdM&jhSiQ z8kn|LVtqBhUBE2go_#tpMxjV?KF{nrm<{w$wPq5iT2Vh{-tEu1&Q9pYD#VCe&k@X> z=ksvEGGbdOo9c1ANRy)wJ~B&w%?Hq2fg=r;@#a`t1BP8n(f>Hn@*4!rIDZzY$D)U>9vJ(S2B9 zxbxY0)oA!K?g4$0>~i{%XZ??~?5KlXv-0bpZ3Z(1!1Dq~&)0T>PO>NPa=R#GC~}Z} z=pH*71#0MR1DA%$@^*A~WlbXA?q_>}3t{7QJ?Y>PW3?g^)4Z9gLeCd5p? z2ZM3~ra+kIb#DDT4+``$1+T<-)|~!4L6>2`!rV0<(S+vRAPy`mqY=+fLpL^+^=m&0 zd{5i{cJ$mH41!&n~Biq!pt8-a1~@yf~kzWa;!U~XWlnx=rDc1`gW zv(?{HX^(sL?iUH?>gD2!4cQOCXL$}nJmZ9Fp21bex7gl*2rBm;|2Zunb$$pJUXH0* zK;0FKzu{aCZCJuygl6D!vS3(}I}hcK<|5yNVg^nG<44cgV13sp+$etObm*~}#^ss5 zzL0x!j822t=EzlhiGr2!fN3?wujeH<+XEv4LEJKfVc|D@XY!`}?Zm7nO{Ri|L^vCS zq#tjZYr)jBef39=n1AVo?UZ+TM&hVA%kZh>P&3~&&Jbfbj%(_72Qd5G@3~UWzQIY) zyP%^hjK9ut?ZQu|7V;DG51{GIV!tafTV}_80N5AaU6^MSSmLT-OuC)V15v-@J7P;c zV3TNQ41$qnDW-Li7+8ZgeVxaJ2tyaJ1;eW)^2Q6 zo!G;d1@5c1x1~_Zfe?%h_TS*=&wbv$G&|&DU{(UTIi}+_KM!Y=3Xm2Iwgf|eQ~z3I z`VdkNw2)pQDknPw9~g4_xC^yopl%G$z|7BR2=M7y9o!kPsMl*rVYuTQgFEz0M!D=m zpZcyb^MLF~C?NY$T8xA{jNx=kI9)U(Wu&ISBzrk}EKsA^C9Xg&#ip>gV|y0G?Nmzr9Cqq+aM-Sx&Z1O zhR5CCU~JxY4Ny#k;y}SM)fEGU^RA>(9{}p=TAzZwYhr9(6_YO>0S2Ub3HeE6ey)C4%V`^g~&EyBK~3Q(u5j zN1u-k4ck?T*0mhjiQ%g#?^>1F3V>H2WrIl}$(zpp4UMxw_nR?CKM!OkFXl#?p3ehR z8sGxX{UP#a-&chr-U1p3ETQYrlDZx+0Rf$N7MzqiuczKj=XsZ8-g zk%4b(J9J|MoC;Ao`4`TeVPs>1)*cku^KxwYsY#EMD)v)#-!J<0PeZx{P*jXYtiAme z#V8!x8*MsyM0(~3Ak3)2Pt_b14>xJU<)nxF-f>~Xi=OxBb?52<$ahnw#BFi@pfuyc zB!+*rN04VvwW0LtV$%Pu)C40N*S z21m!mE7!-4yq7j#z@_-HGgGp{MT2viJdxQttnanr)u=0n)Sx?BGA;TYOEm49C*@As znZHNRKXrbo8alJ=okFHaF-)#JRnEZ^?Y>D-&dE}x8EiXmJKrhC${Ju@vLD2ZGAOdv z23?Z;Ol+}}+SDs>R4u@%clLuf$2L!kjTtjvIh&6M0W=Rhy5o=VptbUyAk13|u`Dd# z4AZpbpX(F+0(F_8eUEr=*S(efTsDygJ>yww{22>|%%EtEfLv?Y4Rui84KnW*8H!&M zR3PH10Gtz767x3Nw-Wjo5Yl;ks_N5cEtyXQX!?!2O7M-#`KdTU5^ECm5aThNQrhsS$=dGXX!l1y>oC);HMuBrLYN zIPPuZEL~yrdf6Xr+gntl_UU(fJJUWp^_X6lf(I)f9PTb0yAr_-DPnI9*B?d&a-_Td zk(Po$mF`w4g;M`X4@QRI7ro_WSdE&!Tm{VV-;*!+iVviaJ0|;1d;_g;39n?Mj8Xky_B1*s~~+(CLGB#GYZWPF-)ONa!0) z{H<*NTh)FGc0|fD2ZYns^G^}rEK|y-ah)z%rxgJA9O2`up3u`5n=WDoCCJ{*A`CnR zgxx`K^YwH5rA>0!dw77!A!i=K<9GLiFDQu^WX)T+cJ2z7ky$XuE{7H|soqloz5j-) z#+b`ER%`iuiQ!mD$n4%BpPXb&W%O!VR%YFh99h;L;da|^_yC!O!Jf`Aa}T~AWPSs@ zC<`&d+zGYNFrLvaHf$;BOLm5pAHZylL^BC`7DaI%cKBwVP5^cvo4rAEf9_-6&<^Dl$#@g||-UYnYoQJvuU1pA}% zXwrPcLv@}s8cfo)*Do4Sl0{b@ibxwCSz80FQp7g=WycMD7g7o6jn%H@4CJN!h|$FS z9naz|Wm?4es-fdrvlF-NkDVC6!#}>`AXj9%D!Hm?cBhB!t5~KLJ3E+-XOFudtoIk0 ze!UR@5CXdpHLj!t&=CAfCP24Gq0KAxUZ`0S`-Sbaf9^@(kbqa<{Z4d!anFmB2(s1A^x9n|RV5TFy{VpE4jEaAAKHk7`Y z1-k!rLmEJ5KmX#08+qdI_@k=v`{;4v9&X3~tDXnR`C8gCqmpGdKwUhXRv;qvOAkYZ zMzH8=D-Hi)jcovZfeTJZt9I`#0l;lDqJl@6P3lR3Eqe)`QR8+6Q@qj0PEV>-eb_@3 zi(yCs)Pr%@p+stu3X;aU9E) zq`F<*f{ao)V#a3^8v|&sB&AE$7-2SbK;VE@!ApVO<%_L9CksNQ^K`PKa}W`gX(eRk zlv~3+VC_Wez0~zWHiml^40Mfdg&7;or=LUZip+USDklIY?7ZY$X1Z5}C?$D891i># zr$oBH7-q~h_LEambTxsTVP2Yc^wYOwmXVRZHBf1oK!1g|)P+dt1p6$sk|k?S(VBq% zyZHo%0k=3X)}KIs!e3F=xmw?lIg$>hAF%1+m-_*VCw{IMY}(oB--#=$O9&!$`!%-* zza;lPnlr&wXVCKT*TRjh_f;*Y$i^H_{eLK|G3M3Y(uT#rf}e8IEMzI>*xcrG2v5HB zhn>t#9_|9X4CF6y3!p_C@S+s;dta*o(g%z3_NbZg%8``uy)Q1VHxT9-W+CZNX1TU| zcO?C_aN9NUsGlHhW-VMFOz%Ib61GWitIRtH9mcpQ0f~ZsyBgo&8sK;WK34Gr1)(KU z^;g&g^R5_rKMMBL1^cd3W+6s8?g>(K?Ey1T#Ym^eU(V?>^86>Cm6T`MwwmOo5a=Pm z;s9iszm*}`^g3j88O+ePiK3&N0(tn62?x~_^|VC-`;b-e$jmH_(fy*A9sS{~UG7Ik zlwW^v{oFge#Znaslh5fJZe3ohHJm%ARi89HG_W+&4rC;Jl;Lg6t}=iUifhw!u>}JU zA0`VI4GEWIKX4^Q}RSW1A~~54c(NsozRD4dc|+RLwFbm$AZJ7tgt{1 zb^aRwCip2)L?jP9mANBxlVc;QK`dGyFo>9Q+S)Z5KwSc-P%;oSTPY8cg4$eu{c`hu zs!$D@2%)!qYw!=qd;H51NqpXmqyE$TbO4(5Gb=qG{;UxJwY;UD88)(``Nhx&{C-d* z_-D?e^T{&rt}ewZG_XJWEWUAg)OC3BXJ5D8e=Gz)1x^iD*ALU$PGP}BIj+o*Nqb1q3kC8f0iXk!_g~RTaAjW> z|HxPH)^mscSxzTWS7zm;BY^IdART5fcQ;$TR`OO{i9R44mxD~+z3#bZH6CWw^8P_8 z6U^a5)5@4{JBTH^d)xrI3ep`>nmS$QGgggn@+OQ27Li;$TOQ9zodEiOGjzoKYi_nX z;XlBT(M6Yt0*8`0Vb3c|{z>;vv&!6xvkNK)m#UfX8jl+xb{}3VL+OoMZhuzK>@W64 zc`YmZ4kZ1`{nDdAbG=az0MA4MZL>d%a*jZGN}QyP0?bResJ%)h#$nrygEfU+>^@+q z12a<>+ogTb$YSILi7^2qS00V`+%o7xw`awS7W~0@62BJg7-T|Jav|fhKBB0Gg=mH! z4+W69VF0F0P6=3@a!UYbt_u1KByUdF`?hzw+%oT8T0%P;o4OMJdn)<|`H6gV2_iML%L)JB~Q5Y?1>k zTo+hPiF+TI1i{=?N!}u7b(mYmJQjHWci$5M6m!iSBU2U9(x6#~xmS|wc@DX=C9AS~7C_lmV znXotxoPoI7WAya*r=8l_3SlRW(-F7Til4RqVr@&)o8LeySIy7>A?r#@(wR)22a zs2$pMbs`<+yeH8%rk8ct_Q`T?&0%=A`miaYHWNoUtqk5}D_ZW94fo}&EoNnwf+19@ zCdwFV>6u!A>>=Nba8!0o)w%*i1pH?fao10V;rp-e_`phsn_PgF;_cqBacJ&N+4hbw z(y6L4K`#!ME;DVHKfF9{vM8?~y;J)Ai5_7_SY|Ew)6I69T$_GlEwMd%i0g$pP2(XF z-xuLtr}O>%QihIVOqijoL}jF7AW%dYx3vmzH@*del$cXviU+UXRz(PLrn4;%$W(Ezl_ z(PsFA)HN{MZKPyvKHPxAe)Rjo9D5|OC9uw&2QWg(l3$a8wcD`^%idF?U60k{G# zZUE`-lFa@>WG2)}P4c$)^`le=bQ?`1mXR1GAy70ym%syhOS`atDNuDPpd0^jFMA1x zxn3xr0u-0|Vf7cMLC&1A1%_z!GFg)RM^q~cp4B(9QcfE`F47zbzx!FWpx70cSNf7HEJyFm68Tzirq3}jx!tm(UINq2laVj9Lt#N>#G)QIo#L-1Om`yli3 ze_}#Z@uK=ytQT8`8;I|TA3B$b&+4C#|2ixFYb+rq!vBr0X8k`p`oGx%c{l$5M;pfr zqBebQ0NymM@^2huzYz9D8c6JN?f&aq`X?m+wQc>sb*=rAOn=kpUOrK>3pAZ~h~^ex zmLM`nDq*K6tYs(hXa~pg917Nw2wq*FdP4*M(;P*W<97u9wZSpn18$aYh9@^FQ`(75 zL{&2$v;$XTSKc_zN`RLl9n#Dk903@zx)lF)e>^SN0X~fKK4j zPyVo1r~1IcV1VRwywgH*q)C+NgH{igIyXY|zYZn;R-oM{x-`6O+>z5N)YJ)n;5WBK zS?oba0{+)|I(pFjFpMLF`;;#}X(^DNvky1{=H>qg?%S;|>FR1-X^)kWmjnX|2Pugy-}r9R8a=dJv%VL%x=vl0s98$|CoMm zh$&Gyvs1ZU`Oi7&C<#tnrwlMdpU)#I5dCy@Apc`{=_0xiKu=QupkSO`=o`Q)AV3qa zBS3a7)Rb@*;>h;b)nYr{_t&t()Cz#6fUq!tp@5dZq}hD;!c)Cvcu18C;B2E)E9Q?~ zJ+$ipDTIgKgqpp-o&r>t|5p0W4*@U7t~!_Qlk*e^q)GJk%=j*t}E z4Z)}M+JH!)7Gc+H80~rNbTLJe3oge2JSUr0c8`2uMkiNx%z!Tv$vvqDQ<$k(v0EqbI!(c{!o^es$h&qnKmDWsd1@01H_XrSqoJpNS4Y!|ir_iuq zAM#^5P~(|I;k8tN>D^VQe%$-e%a9(AKS$0|zLoR2kf-x|g&-Wjt74QhMSZ2~%ig|> zEM)gz6AP^Xb=Q52&bx;Iea8^#Tiof%dIg-1;^*{AR)7W-mqUcG|w{wa}wW^K^AYK;7HDQ^>Q@^vtX+eUdHD0WmB$e;kJ8*U)Er;^6) ze6=&Kr`_Kbl0m(m+PqsZ(0hay{MTvZ+Al-xi-#TJw+XBQ3$8L_#XOvS~1*wmyca*4#T} zb|zOFuB0Zv$W#&&+c8?9iuwJIBg6`mt>1RweoH?Oe=z^VwagC%L=c%6V7fpDtd?9H zmU1bx%pa*XoT&cc#nZ$Z>*m0x_Cb0?W+qtl#B6ERK3Soz{oZ?69gxOjLh{EKtegDZ zHg9bOKx4OnnEhZQ<6a0Aj$}DlhZ=N$Zr ze4^_E4DkntS4IO#^xcdKlk6p@`ch;CRx*GL0&r6O0GJ7@7;c8ea|8avu@e^!FroMb zKpO$6V_<(DQ~QI;9okMdUH~Gi10;0!+~Mp{jpm8JJVVS3pf)lmqzHc+#W(<%z;o~e zc+vjWD$X6L%+@6-U7&AWSfiWD20FnjtdoL3chHUzLfAO8q%^Ql2aPm%)n!fvrBBQe zr5a%00&eOcFs+&m%%sF+WA}_U4{Vd42>Z5J?Y~{q9v1sBu#oyCIPA91u`Z0h8#*Gl zwb#^tXg^&^*xH{3q&VyKFmyK2&Co3)0JL8s8($9Nn$=K90L-n;H}jywyb&&iQY3aLWn{KxfJ0~}TFGi6=>S`;{d z@?Aq-_VvrMvhvQK>$;%AsrodnD=W^EeF6xm+~vc~hoMqk`(-cH9X_;T9bBA1!4J8F zO9Z9nVI&Wb%m6J^e(9bc@a4eXU$sZJ(`>2|GDQMh3KqZV5?O8_$_Ci?#I%uFdSw{5 zN6$5iw3v_v!cVg*Dzg%6QeKxEwKV~6uW3H~0T6<}e;2^aGGV0>nD$<*;7>!wY}P@p z|8voxI!_(TJHU}h{2$TO*XeCIYq{_ZVBI_w8s9-OR#lwt{qu#@bj^GqtGPh*d9*KN zS8Dn0W>&rhiYgXAHSyh~EIe$@*E>*)NP;w{*4NuV(t4x;WGJ@a!OU(DC9u0|jio!c z+ZA1%s$;&-uJt|M^AeOC@?$3uz?y-!8JOudmrLe8jheM#!p&7njT#O|ib`Q<*>L&YO@ysS|i+$mbw=ZTaF+KdDs_OAI^oLlvIPk3>l~@gF}4wHalcJ>UyeuIB+L z`S;db9ofw+_f}}k_nkjG0Zyhhw7I?eE&*WE6Yz@?3nhcZSY(3U zNWY&@&HNH84ZJ5u=&w@f&i5Y_pFIK?om|3h4>k)hHh_-do63ldA^_a9=;(wfr*B;f zPca}KFIDCx4|kfrbDHIdT`CTe?sB0;*A<%iF4TA#z?qptgFbK8E5Zf`G7N=20}&er z&;tOW6>mSQ&+j4ZYiY5$Pt+Vpl-f=JA}I3|>@s3G0VcVtZ)S@|S=|&jI`WIj?Zr})!ssid@)aP36E%#=_~T>NP8%jnIT=WR za&6XHHh0A|7+7OUH}nN9>P?f_?;@SNbo()0KKj%>*=XS-rTXddG@UB_=Hxp^ zuoo}!mLd*gvSw^<)Y_6N#@cv#)C76QXxeC+Z2dUbc5n@y3i70jz2+}2Cf@#N8o*r~ z>Ch_|grvm#t>)MnH7ct`3Ei;fMQ5)w--rCOh_u!xDwIt=!-&idG_KA zc9H>A=qf>R|8a9R5G*gf@||N<0%{TPGpoNe1~TkTeDh5sUfcGyPTKtVbi!ccPu#dF zBE`WsJ_5%IXjJlA1#E*ziwQ8_xcr192iJ<|WeeLn_X`k5tsG9C!AJ6`K(rqL^Rp*+Rl$cw9@fOgjhq@09B!2P#34&!iYa6POXUoG$w7)#u1&P+qpw zl=GH;aUvy`)1&I(+R2UXZhln&u3^a*$2(mA)HC4-L`?0!0@hHR1N{O-UF*mtAc!M>l9kqEv--TkRILS9 z$2h*T>tm*P?@7!dzVp8A-hgaG(E<=(aQi3N_6IN zCHcmHJSFM^J*7CfduOLa|N1ZZ0~?FV{UYMxLF+JptFs(CKtkUHANB@MF9b{%Ta@_(e(9jsxGp0UoAOyDVrhEvfc)_f{=vOB?05841OW;C%`GPeE4a}*vkv2=QiktHw4of5A91j zJtX_J*k|sWkE}qkIyziCPR=;klTQcp{`2)Wc2qnEv;l3vEbIjZrFn+vV!eZi7j z8GYA>#eQwnsg=tAxQo{-sD_tULm>V7E**`u_ZP8h3}kBFudTf}E?tD}WIJkbCFl0M zeH@B3)~trz8bLEQ&UlKVH#~+H%&VLI(wexAJ;~lpZAUUM;%qL?_;q_dZ)1F%|H*Z08e_X+tSjn(*t!9EB?{4;{ta$5|SWmbvAfq=l7I-qPN9=Sf+0 zlIN(pM0h1FcS%4}kBSboEC;tzOlbS|;1E~LCQBO~$v--{<5nJL##b;Fmubxq=k`X- z*t>x>A$Ip$pgzqFtP z&OodVYX5{RPIxlEy!t{F{NW0gV!DeAo&n*6?K-XdbG(Sm9%l)I^J(o{3@2>^o9?f4)o5iQRSgJ@z43*nlieC2PD4F@`#%mp z24(Q+`4!CvDRhl za-CV#rTmeQGHKruUp}7hh~zGVOfN;{DGm9UC{uKEkN07<#zsh9uz#*(MI$ypkiPUh z#1+=pmVG6J8D$tNkzU$u<6?6r|IyE`p@@6#S%IR6y|tmc zkH!<3pWWMcAqWUW$4pqdu+HZ%Q}=7epB*QZPz5&W0Eqjdg2W#|&ZO6G%$pRq3~icn zw-3{il$55rR5@aFgUW+7uRjNio)`9`Pn|pIDhbZQbealWSO4ij@u)TMjg6dy?<0Wk8 z4=DQYB7Yxe_D}DVe(WAe8M*Aj`ijZ>M*gPf@qD8fi+>zu%22sLNfElPiIc#(0eIYZ1y)~iSeZI>dEp-tWKbeS*u-zKJ*W*A*?#G z!CuHja9D{G6s(iu4Zpc2{p+_+n{@p7Kmuz#D`1RUTVCC}*TTUUyL?`V_}%)7Y6cUlCkXXnMB~zbz4KFBb)+tUNwn0sbV+mh-a1 z=G?Nxvt|b*`;fU#=aR=)GG)t0$9zBb!u`4#UnBYvnxlpnYkN1GVC#9oCwK6Q>ev06=4CTv7q+j&~ZQW$fk;Vh6%- zW+q4mUwc+*%jwJ--a(W8(#76^L>2|<*40{JR$4)N6{fjCLDHOqsRCW50)pOKb+gl5&OKJO;6a36 zqQq?HM~h(?M&RkqKDyY;5d()K(#?_99cJygg!tZU_j>BfI->M?JUL9i=D#9eE3TEidmLl$673uTp1+Z z@KxuoIVzw4Ey7A`z4&{e>RNQhTa67Q%slDxnRW40J%*V#t?YNv{3Ja-z8&BG7{9YaMMh-?+_*!1xP7TIjSH+$ z+~?5V(}0i3E6mPdt)fgi@;z9}XeTLHT>tQsSUft0oU*fn@)UM*qV;QWw3u|iY;_R9 zjH1flZM%$DdS&vCB* zojmW6O`2>nG$m)#-j&Nrf(Xy$dSy^rLZ**ug6G=yEjotCw!$mlfbD%C7fqO6#SA-v4fa zhnW2YaiqO-WE3Vl1_*h3x@d~yue zxB3su?UBrsLcB_*b}B0&uV$>lmC?cBQ_Q+;dCn~B@ZLj;nQ!NnXJ@#KNy$b?L?KeV zgXte4U;omOJ&N${vt!|-yZPiJx&$vsBl`yoT>FD2FUGt(@X#W@%k?Mo1>LLDq?1+O zW^TVbFk<8CEU3eO7OOlrO>htdTayWK%$M+q)&37v6YM(t@p@PT0y9a7qM$HW?f<-LI zL8O}5(0L**uIpw}e|Us!Zcr^&HfqI&QAxvA*Z;6XY|Po*`J}{j^TyW`HC`p9OME{0 z((bfXR?GiPba(Ym@rqSho&=||-~4KPIH8??kR?GYje@4JbGRIuks^l{|F|LHQr~v> zvf5F42Hh=nkJ^#A4=sn=PQT!qkX>F5I6Aywz>AEr6VC~b)0w$tKzs4!R%(9#!49r@ z@yf!Eu_7PmK!d;O5Sll|*J#a)X?YHb1+#{2Y(G213~8PrN+a3b0wX4Z8f9+99Of*P zXb!O$bQ#RYMOTcV*SC!Bht)Pmucap#8~Xo~SJm4Te(zlS^1vz40XN;tpL*v$*Nh1j z>iWKnU$}jXc;}3&VVfGqBQ23!*?e?>6gM+Hz77^FYm{e7t&HeucQ!icgy3e+)+V%P z7RQw5iA{ZvF+Fz=a!5YGfTL#Aq66k_G|{qn zT-mG`c5eInqGK_J<;RRn{gTB}te^3g*}5xrYFAwSf&WHX6{0k+q3T}AsdRl|mWMI{ z%Y-{4Tj$rP`@TG(4L|+$_%YUdmzDUKdz=~$wCO)($z!)MdMvl#D3Uf&IC*M+UjT=+ z$K!%y3i~lFl=kVR$2Lb!CdTNKsz`Ct9euGsEJ7 z4LI2|YcXrqDh|S~!p^ta(u!%{eMCBW?YGASPO#(2Df8G-bghI(g;`%;L4oYOz6%hw zpRxNlkvB(i^KPCVp2O^B)uRbvHQTTgnROsh5LrKM;bL0DU{*r&I^eP93m(Sp%AdFg z9JOZ|+!xZvySi)@uN+dG$VH~z7FZJUT~z90%d0RkJHWtRIZ8*d!J3X=C7}QE?wx1V z%O9{k7edKXkg1Z$*td;xmN<>^GrJa@mh1cCO4YraW74EY1WG?=y1a0k4|&U~gRK%1 zY}^_wPo@y>)8*)L+Cvz>qij}`zCgdn$vO{?)&Ns1Kj7Vkp|<&GjiPNCD-k!Gg>P>HY|uj z=WmlqweN@>;dja)Ge8{}iyL#QTAW|x8}&|hckdJ&sl-KR+q-=uRGGMXo!eh_5XDu| zpk8L1*TZ!pa@LT1TbO(8HiiSbsDwoKw8;Y>slu1t^66(%tw(KX)92RteLjUmkD)X| z-cLG+@gu2KlcqIB^k>7`+47xPK@NL_U*M%xjfB5-f``qwwFPI31{~7^FGfjKwGigwR!6k^wA`ijZHswP z0r`9HztBe6BAf6%+z=ZL+oqgms&5QbwS*}e6iLmvt=HD}Q)(88ZvA8Bj0do~C?3n#9ulBaiC9r}oIuj&;c ziD;C)bb>uGs_p8bh}gX8hOM8gOorxygOyV{89Fd&DvQ!Lzh)K0|ei<=s<6i3UY`Yr9sfC1vxMj4_8vibJ!^$XI_N4&nhxVC)<0>jh z%GN3PNtNKB!fle<4o+^zrDH6#DLSgv>)N{_Lh2PMqp}VU2jWQT_X}!04wV;=Uu<$3 zw+^w0i+aI^8**t9`jVH9-7Y#YbfqdEc@_;5p)B~LS*y*`_g58wTL{N-B*yN0oUYJf z)Sjo>x(~UA?Q3oG+$=>#k;K|VpHG?H$eGNt>K^cNtEh1fZxo+@IPOTrH0XX1IYEut z`vX5=-qyD+*J6GoaBXWi6yq`e$Xbl_Bwuj0tHm`m@C*p&1w&fiWLH`#P#dOWdnN=X zp7JH+U_n=v1;%BOjxZ6Rk=B z==zp3d(5t(tFw}O^xv+m#ci&uvkzjMJlGt~j{Bye&!-I!brY^2UXgT+&jgeWsb*N` zpQQ`tomQI!fYHQk0u#P>HJwc8;_*Tc;f zF!g#^OHD>#x~%UI+4UtNT9g#VI0CCUO#q-o*_;N8(I2t zeFKAb%ef0rU~0EQn`FKELR>iwgA(t)yX26PoE_hZEXMV2InBW1AQ^I8G;)4D7&K}= zk+lrBxj39tgxYHIFr{|LLglBCPUv6k5x4fr`PN>V0S4X&{>F3eqTfcu6^$jF^?b0$ z^^6^4`Rpef(u!`WbW}ELT+L5@G1BqzhP?Y`np356Q~oXM%K=$ zko}q|PbCLTF_l)`Ymr1)4yL}ztW=vNUo06)Z5>Rwl`#ERexhV?nuquO82oaBOy~5B zD+Dd}0aK2|Yl~Pti%t=Hq@a@c?u-XI!y+RmGFPrI$`~azHOHyKuRbm!zoe35dA{`B zcsoXP8(mrd?LJPl-mOe@)w>41ib-g@!N<(oarlg<@g1aVZ8?7GS~O1kGC!bIF9fG$ zi?Mz@6n#BwyUFW~L>=uTBLKRd8GnU;?cl6&Zrc7J0CFTggWe;ae}8w|?2hG^)VCWv zC)DtJ_u<}51JOx+?2cC>F({o_o{9${{KJl=u7$w(-3p@Odgo76S;V&%kzwuCCda19 z#-D2)0;-q9Q72o4K%S0xb=0;q&}!2(qhVwQG+#KD?_F(#HkC;z<7`+OE1-azBu||) z7C$t4~ZisVje`ds7l zUdO*%rg-kT0{iq2wY3naPyTjyu)bpCwW{><`!BbbcZEa)Uff9nB|k#*qFT=+fJTP$ zXAln=aVG=KT+F4oLN|0i5S9+D-`u*NbJHhET_8x~cF- z3((Tr60okjF{3slyS3T!0*0U{mXUOcIill_oG|$xuf*N6EJgfcP6A)4joDnMzD`&! zW-G<%orq{mHhog_M<#ct2XJ(pNvdf={#xLX!kk&!4foP|N}^kJy&`oPY!w%$1%ufi zW&Fdi=8xYL4Gp$93=RAYyj-bw|2t)fbEsVq{yL_4SVHcOaQ^T>bmc!z04}>-d1!KO0md&i?ghGi`*@f6gR$c>Ou;wM+b;)am~- z<$w3`|2Glu3^C|Y0L`r>Zv0L;cb-@?Zug}|{6lVuq0PY}>ipKVlGg@E) zrRU*3HTQqZkbXa<-5%Nq25XCzKr4%tOh4N#ToJ|h8cMbF<(cOlTMF>kp7?uf5sh~{ z1QbmF^lXW}KR{ptodSQ#0a|M|Y7wwAX5I?$fsZ)(!6p14oZ^u3=M;yt$pK#L3~m#{ zw?}bTN2xy$wYU&Bess5n6Yr?}Bvb9rNeYboyO`BotI0_xJEXj$k9?qdwmP}3xLeM( zm7MZasJ!LRGrXRlB5%d8xN>v}NMJ!E?3P=vxKlr2uHrB&0~XUi=R!F72b%5X|KQk^ z@JTX;+=OTG`e$o$!|pj~BKYU2p#OH$%1UU-x2T^A4bulm^Q;7JjQ@IAZR*I_;_D|a zWuck7Ul#wh2_V#skWivRc7=VwU>*ZsOEx{x`)A>Mm{86dqv12pRn?%FULy0PLUI@RVdWZUD~4w$oo{1xrN}5()OVpFUjHBJM#v zWZU&!XW*}LsAU0g?N_7aMDMu6BS1pIECLvrOXoQH=jhWnt~ZQ@zjhNN?S4unz5t-L zL*QC&ZAePTFo)I^W-U!VX6L4r_y_14S$j@B_O16=d36-Q7`JP(9=N))=vEe?RTXWU zcs^x4c#CIy&4z`>`jDb=`-bla&&Tjp;~m_i!k~w6kWHYs=0U`v*5;NW8x~F>YIe2T z87+oAd8r{a^Xhegy)gBqMjLw&y?SFVMJz`;1dhU3J+(;9qyo9OItCoQ{{CJnxT*U7 z{9i+f{>qRR*O_f5ejJ~KD;V!R)nxDu*~_^OsKrb3YSRtkZXLy3jgKDTbQAy{)!jfS zSAg3QdnXEawR`ItG2RyJc(cw7dyX{`pBU9#_AW#}@?rLkf;AZ%(w8ccY<|gF> zZQb-`+&$+TM=4%$ts&E|Z7(rCCwcga?s>@Ki190<9*gquLd_NIq9e)Mkx|N{R4#`7 zwGH){1E)oMjQ?C%G5hOr*1*IF<2y%t3xm?(U90oPQ)fTWiZZMj>`P&bIKKxm@8Pkd zk#24Y{DMu`Tve!@5Ab9AwB(RV#htr10;IPb&8_82W?7Fs`6TqrhGh>zoM~Dg?5ANx z!HuDH(?>>QN0zQ^jkq#s=`uR_ij&J1ue(sT(xKhdn3*|z1I`42kmbokHebZ@(lOc# zkQ6KRfBd%l(|5EY7^SP9K{*ws2cmIty-*4s-%GSrDmkS1h_Pg^Fv7dZ1djATeAU$` zZh5u$s}M-WH)PKRL7y!XfT;$+7c?eZTu`m4@@3yZN%c)W7c)1I_%Xd>CU`;>Z$|}z zB4B(SYZc$t?2Gg^am^F1xl}maknUjfFE2P$_$3 zGqT#7PL3QxQj>c=7%a|41)g;0PnqJAWAE6h6YgSN3v6pU(Z7D~E=L8URhkpY*EOMd z3){kH9J5(#INk%5)lE_!|I00ll!@Lq*DlLx430llcQ!mZqcTWhK!}&YsTz)4zO)69C}uN@pT^0dED8g##Ounwn*Of_^dW zX&+>#d_Qefwt#sokQQ$>y$iM|*EsN-y!pbSZg0PNO>J60aT6_Q9=jflk0MS~(hC_z zdq`E3l{2bT5HNWRky3%^%4j*Ki)q8$z*ws4XEzLp7m&*Q^3+P}4}$=m~le^K-c zpLG5FVjGNuzq&56ES~bh-q>d}Zpc|uxpqLezTMZ)>po^WwHv?)G7*0c02Y0sDPb#* zgd5FY9Lr9vP@%^)rNL^D>tg0xO84tL|1V6A6HbKMoJ9 zM^cFC_!KU*RSB4tB&)tM51%m%njU3Azqv-%y;7F_VIr_VKeEUt2+{p zYNN-mmLHSPoom}1r7or?5#ZAInC=K63tpA2ai97$NJ3yjTc(?6y2kse0T z6ma=efdyJZ5X4b00lynN`BUu@^1V3Q+D~<$1V}dRGL^22DD7!x7qOBjp1{;i!aeoq z6_f*<_I0MGI5m@XPWkLWET2RB0}*r9^-N%n4Sp~jz7XHJX0+$cy5vCyc9)+sP%ckJ zJ{qs)$!U)E@L23sva?RE@l&qZ+)<`d21X`=iCt~*tTEIE8`yEz7VPj`hv!3fc_E=A z^L;bgX4uTAwK^~+2^4jIO%c$oj?9?VWk7r#z;nCW0xhJojW_Cd^I?_ouAZh8gG$2Ih1CYNOty0IB zvRM?Voy1nR@C|UBF1(1?>K3cCnAX>Z(a6RuYpTS-@oVxlOic5pyVfLmMc1W{?@Jf< zS0tNPpHan1dO9c!Vf!LYWQnLbWZVp=q3cF`%srN?f8t*1sN%5kl#!@L{q_FKesw~k_b1VI#$bhT2l**Q0^iyxF z%gWlei^GGI!-zs^QbrFFjsveF;Y^XBT@Y_rtmd}N1qr;o-VBIWp4SY#1E#9f0n07q z%11@bR=2Sre8AN$@d>nC-AzxPx;G zm^E2c%e25&N_sN>fhv^>8PP_=c61{Nw&h7wA;`=*j2`N=;U=AEYEnBjX_m`>`tTvdu*{2*C> z&$x1-W@hfd)&Ux`PL72-e!;SowV9=FrIy8g4;*qp@0P$`J_1~9vHN=W<19!d+_F2< z%OZuU?zLUkX#-8hO>2hdt#TPHbJAzo2b>A7#ptN$OL^f`u`a8$q=obn#8aRe_&aQk zM8iuf6B4Cw-mJ8gfL{ifdM_JV3ZjakTz6htDYhy{baw($pM?b)kU^NPpYfgEQmLG1 zrV^1QI1Pa5mBgnN`IsZ3_KZlT| zr0{{H>9gRW9V0(G{?Gv3zRbu=~=eJTq85XIc(?b*HWYV>?M9A~L6cA|L=SlcR zu9<3Ik9M*atrr1e2iPfPB`f~K7w~j$?h|^-$kwSfVUKmy*#TF?7l%3%dYdFa$tuO^ zv?s@≫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 + - - - - - +{{#if helper-text}}{{{nl2br helper-text}}}{{/if}} +{{#if error-text}}{{error-text}}{{/if}} - -
diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 904e521a..321d2c97 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -1,52 +1,12 @@ .nsw-file-upload { display: block; - &__helper { - @include font-size('xs'); - display: block; - margin-bottom: rem(4px); - - &--error, - &--valid { - margin-top: rem(8px); - padding: rem(8px); - font-weight: var(--nsw-font-bold); - color: var(--nsw-text-dark); - background-repeat: no-repeat; - background-position: left rem(8px) top rem(8px); - background-size: 1rem auto; - display: flex; - align-items: center; - - .nsw-material-icons { - font-size: rem(map-get($nsw-icon-sizes, 20)); - margin-right: rem(4px); - } - } - - &--error { - background-color: var(--nsw-status-error-bg); - - .nsw-material-icons { - color: var(--nsw-status-error); - } - } - - &--valid { - background-color: var(--nsw-status-success-bg); - - .nsw-material-icons { - color: var(--nsw-status-success); - } - } - } - &__label { margin: 0; - } - &__text { - display: initial; + &-content { + display: initial; + } } &__input { @@ -61,55 +21,26 @@ &__item { max-width: rem(500px); - display: flex; + display: none; align-items: center; justify-content: space-between; background-color: var(--nsw-off-white); border-radius: var(--nsw-border-radius); - box-shadow: var(--nsw-box-shadow); - padding: rem(16px); + padding: rem(8px); &:not(:last-child) { - margin-bottom: rem(16px); + margin-bottom: rem(8px); } - } - &__file-name { - margin-left: rem(16px); - - &.truncate { + &-filename { + margin-left: rem(8px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - } - - &__remove-btn { - font-size: rem(map-get($nsw-icon-sizes, 20)); - background-color: var(--nsw-white); - color: var(--nsw-brand-dark); - border-radius: 50%; - width: rem(16px); - height: rem(16px); - display: flex; - justify-content: center; - align-items: center; - margin-left: auto; - flex-shrink: 0; - border: 0; - &:hover { - cursor: pointer; - outline: none; - color: var(--nsw-brand-accent); - } - - &:focus { - @include nsw-focus; + &.active { + display: flex; } } - - .hidden { - display: none; - } } diff --git a/src/components/file-upload/_guidance.hbs b/src/components/file-upload/_guidance.hbs index 51f521f3..d473fa6a 100644 --- a/src/components/file-upload/_guidance.hbs +++ b/src/components/file-upload/_guidance.hbs @@ -24,11 +24,49 @@ layout: blank-layout.hbs

Truncate file names when they extend beyond the width of the parent element

Upload status

+ +
+ +
+

This functionailty is not included in the base component.

+
+
+

Where a user would benefit from knowing the status of their upload consider adding indicators.

+
+
+
+ A loader showing the process is pending. +
+
+

A loader can show the process is pending.

+
+
+
+
+ A tick showing the process is complete. +
+
+

A tick can show the process is complete.

+
+
+
+

Multiple uploads

Clearly communicate if a user can upload multiple files and consider displaying additional files vertically.

+
+
+
+ A loader showing the process is pending. +
+
+

Clearly group the files and allow users to remove each individually.

+
+
+
+

Error messages

Error messages inform the user when there is an issue with the data they have input. Provide helpful error messages to clearly explain to the user what the issue is and how they can effectively address the errors. Check out GOV.UK Design System's error messages for file uploads for some best practice examples.

diff --git a/src/components/file-upload/blank.hbs b/src/components/file-upload/blank.hbs index 4af420f7..34852e39 100644 --- a/src/components/file-upload/blank.hbs +++ b/src/components/file-upload/blank.hbs @@ -1,9 +1,30 @@ --- title: File upload width: narrow -model: - file-upload: ../../components/file-upload/json/file-upload.json page: true --- -{{#>_layout-container}}{{>_file-upload model.file-upload}}{{/_layout-container}} +{{#>_layout-container}} +
+
+ {{>_file-upload + id="file-upload-helper" + label="Upload drivers license" + helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" + required=false + }} +
+
+ +
+
+ {{>_file-upload + id="file-upload-error" + label="Upload drivers license" + helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" + required=false + error-text="The selected file must be smaller than 350MB" + }} +
+
+{{/_layout-container}} diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 70aadbc9..4b34dd4e 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -4,221 +4,113 @@ class FileUpload { this.element = element this.input = this.element.querySelector('.nsw-file-upload__input') this.label = this.element.querySelector('.nsw-file-upload__label') - this.labelText = this.element.querySelector('.nsw-file-upload__text') - this.errorMessage = this.element.querySelector('.nsw-file-upload__helper') + this.labelText = this.element.querySelector('.nsw-file-upload__label-content') this.initialLabel = this.labelText.textContent - this.filesList = false + this.multipleUpload = this.input.hasAttribute('multiple') + this.replaceFiles = this.element.hasAttribute('data-replace-files') + this.filesList = this.element.querySelector('.nsw-file-upload__list') this.fileItems = false this.uploadedFiles = [] this.lastUploadedFiles = [] - this.acceptFile = [] - // file-upload options - this.multipleUpload = this.input.hasAttribute('multiple') // allow for multiple files selection - this.accept = this.input.hasAttribute('accept') ? this.input.getAttribute('accept') : null - this.replaceFiles = this.element.hasAttribute('data-replace-files') - this.maxFiles = this.element.hasAttribute('data-max-files') ? this.element.getAttribute('data-max-files') : null - this.maxSize = this.element.hasAttribute('data-max-file-size') ? this.element.getAttribute('data-max-file-size') : null } init() { this.initshowFiles() - this.initFileAccept() this.initFileInput() - this.customEvents() } initshowFiles() { - this.filesList = this.element.querySelector('.js-file-upload__list') - if (this.filesList.length === 0) return - this.fileItems = this.filesList.querySelectorAll('.js-file-upload__item') - - if (this.fileItems.length > 0) this.constructor.addClass(this.fileItems[0], 'hidden') - // listen for click on remove file action - this.initRemoveFile() - } - - initFileAccept() { - if (!this.accept) return - if (this.input) { - // store accepted file format - this.acceptFile = this.accept.split(',').map((element) => element.trim()) + if (this.filesList) { + this.fileItems = this.filesList.querySelectorAll('.nsw-file-upload__item') + this.initRemoveFile() } } initFileInput() { if (!this.input) return - // make label focusable this.label.setAttribute('tabindex', '0') this.input.setAttribute('tabindex', '-1') - // move focus from input to label -> this is triggered when a file is selected or the file picker modal is closed this.input.addEventListener('focusin', () => { this.label.focus() }) - // press 'Enter' key on label element -> trigger file selection this.label.addEventListener('keydown', (event) => { if ((event.keyCode && event.keyCode === 13) || (event.key && event.key.toLowerCase() === 'enter')) { this.input.click() } }) - // listen to changes in the input file element this.input.addEventListener('change', () => { if (this.input.value === '') return this.storeUploadedFiles(this.input.files) - // this.input.value = '' this.updateFileInput() }) } storeUploadedFiles(fileData) { - // check files size/format/number this.lastUploadedFiles = [] if (this.replaceFiles) this.uploadedFiles = [] Array.prototype.push.apply(this.lastUploadedFiles, fileData) - this.filterUploadedFiles() // remove files that do not respect format/size this.uploadedFiles = this.uploadedFiles.concat(this.lastUploadedFiles) - if (this.maxFiles) this.filterMaxFiles() // check max number of files this.updateInputLabelText(this.uploadedFiles) } updateFileInput() { - // update UI + emit events this.updateFileList() this.emitCustomEvents('filesUploaded', false) } updateInputLabelText(uploadedFiles) { - // when user selects a file, it will be changed from the default value to the name of the file or number of files let label = '' if (uploadedFiles && uploadedFiles.length < 1) { - label = this.initialLabel // no selection -> revert to initial label + label = this.initialLabel } else if (this.multipleUpload && uploadedFiles && uploadedFiles.length > 1) { - label = `${uploadedFiles.length} files` // multiple selection -> show number of files + label = `${uploadedFiles.length} files` } else { - const singleFile = this.input.value.split('\\').pop() - label = this.constructor.truncateString(singleFile, 35) // single file selection -> show name of the file - } - this.labelText.textContent = label - } - - filterUploadedFiles() { - // check max weight - if (this.maxSize) this.filterMaxSize() - // check file format - if (this.acceptFile.length > 0) this.filterAcceptFile() - } - - filterMaxSize() { - // filter files by size - const rejected = [] - for (let i = this.lastUploadedFiles.length - 1; i >= 0; i -= 1) { - if (this.lastUploadedFiles[i].size > this.maxSize * 1000) { - const rejectedFile = this.lastUploadedFiles.splice(i, 1) - rejected.push(rejectedFile[0].name) - } - } - if (rejected.length > 0) { - this.emitCustomEvents('rejectedSize', rejected) - } - } - - filterAcceptFile() { - // filter files by format - const rejected = [] - for (let i = this.lastUploadedFiles.length - 1; i >= 0; i -= 1) { - if (!this.formatInList(i)) { - const rejectedFile = this.lastUploadedFiles.splice(i, 1) - rejected.push(rejectedFile[0].name) - } - } - - if (rejected.length > 0) { - this.emitCustomEvents('rejectedFormat', rejected) - } - } - - formatInList(index) { - const { name, type } = this.lastUploadedFiles[index] - const typeStr = type.split('/') - const typeSpec = `${typeStr[0]}/*` - const lastDot = name.lastIndexOf('.') - const extension = name.substring(lastDot + 1) - - let accepted = false - for (let i = 0; i < this.acceptFile.length; i += 1) { - const file = this.acceptFile[i] - - if ((type === file) || (typeSpec === file || (extension && extension === file))) { - accepted = true - break + for (let i = 0; i < uploadedFiles.length; i += 1) { + const { name } = uploadedFiles[i] + label = this.constructor.truncateString(name, 35) } - - if (extension && this.constructor.extensionInList(extension, file)) { - // extension could be list of format; e.g. for the svg it is svg+xml - accepted = true - break - } - } - return accepted - } - - filterMaxFiles() { - // check number of uploaded files - if (this.maxFiles >= this.uploadedFiles.length) return - const rejected = [] - while (this.uploadedFiles.length > this.maxFiles) { - const rejectedFile = this.uploadedFiles.pop() - this.lastUploadedFiles.pop() - rejected.push(rejectedFile.name) - } - - if (rejected.length > 0) { - this.emitCustomEvents('rejectedNumber', rejected) } + this.labelText.textContent = label } updateFileList() { - // create new list of files to be appended if (!this.fileItems || this.fileItems.length === 0) return const clone = this.fileItems[0].cloneNode(true) let string = '' - this.constructor.removeClass(clone, 'hidden') + this.constructor.addClass(clone, 'active') for (let i = 0; i < this.lastUploadedFiles.length; i += 1) { const { name } = this.lastUploadedFiles[i] - clone.querySelectorAll('.js-file-upload__file-name')[0].textContent = this.constructor.truncateString(name, 50) + clone.querySelectorAll('.nsw-file-upload__item-filename')[0].textContent = this.constructor.truncateString(name, 50) string = clone.outerHTML + string } if (this.replaceFiles) { - // replace all files in list with new files string = this.fileItems[0].outerHTML + string this.filesList.innerHTML = string } else { this.fileItems[0].insertAdjacentHTML('afterend', string) } - this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) - this.constructor.toggleClass(this.filesList, 'hidden', this.uploadedFiles.length === 0) + this.constructor.toggleClass(this.filesList, 'active', this.uploadedFiles.length === 0) } initRemoveFile() { - // if list of files is visible - option to remove file from list this.filesList.addEventListener('click', (event) => { - if (!event.target.closest('.js-file-upload__remove-btn')) return + if (!event.target.closest('.nsw-file-upload__item-button')) return event.preventDefault() - const item = event.target.closest('.js-file-upload__item') - const index = this.constructor.getIndexInArray(this.filesList.querySelectorAll('.js-file-upload__item'), item) + const item = event.target.closest('.nsw-file-upload__item') + const index = this.constructor.getIndexInArray(this.filesList.querySelectorAll('.nsw-file-upload__item'), item) const removedFile = this.uploadedFiles.splice(this.uploadedFiles.length - index, 1) - // check if we need to remove items form the lastUploadedFiles array const lastUploadedIndex = this.lastUploadedFiles.length - index if (lastUploadedIndex >= 0 && lastUploadedIndex < this.lastUploadedFiles.length - 1) { this.lastUploadedFiles.splice(this.lastUploadedFiles.length - index, 1) } item.remove() - this.updateInputLabelText(this.uploadedFiles) this.emitCustomEvents('fileRemoved', removedFile) + this.updateInputLabelText(this.uploadedFiles) }) } @@ -227,66 +119,10 @@ class FileUpload { this.element.dispatchEvent(event) } - customEvents() { - this.element.addEventListener('filesUploaded', () => { - // new files have been selected - // this.uploadedFiles -> gives you the list of all selected files - // console.log(this.uploadedFiles) - // this.lastUploadedFiles -> gives you the list of the last selected files. It may be different from this.uploadedFiles if replaceFiles option is false - // console.log(this.lastUploadedFiles) - }) - - this.element.addEventListener('rejectedSize', (event) => { - // event.detail gives you the list of the rejected files - console.log(`rejectedSize: ${event.detail}`) - - this.errorMessage.innerText = `The selected file must be smaller than ${this.maxSize} KB` - this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) - }) - - this.element.addEventListener('rejectedFormat', (event) => { - // event.detail gives you the list of the rejected files - console.log(`rejectedFormat: ${event.detail}`) - this.errorMessage.innerText = `The selected file must be a ${this.accept}` - this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) - }) - - this.element.addEventListener('rejectedNumber', (event) => { - // event.detail gives you the list of the rejected files - console.log(`rejectedNumber: ${event.detail}`) - this.errorMessage.innerText = `You can only select up to ${this.maxFiles} files at the same time` - this.constructor.toggleClass(this.errorMessage, 'hidden', this.uploadedFiles.length !== 0) - }) - - this.element.addEventListener('fileRemoved', (event) => { - // event.detail gives you the removed file - console.log(`fileRemoved: ${event.detail}`) - }) - } - - static extensionInList(extensionList, extension) { - // extension could be .svg, .pdf, .. - // extensionList could be png, svg+xml, ... - if (`.${extensionList}` === extension) return true - let accepted = false - const extensionListArray = extensionList.split('+') - for (let i = 0; i < extensionListArray.length; i += 1) { - if (`.${extensionListArray[i]}` === extension) { - accepted = true - break - } - } - return accepted - } - static getIndexInArray(array, el) { return Array.prototype.indexOf.call(array, el) } - static hasClass(el, className) { - return el.classList.contains(className) - } - static addClass(el, className) { el.classList.add(className) } @@ -300,15 +136,6 @@ class FileUpload { else this.removeClass(el, className) } - static setAttributes(el, attrs) { - Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value)) - } - - static preventDefaults(event) { - event.preventDefault() - event.stopPropagation() - } - static truncateString(str, num) { if (str.length <= num) { return str diff --git a/src/components/file-upload/index.hbs b/src/components/file-upload/index.hbs index 58125452..ccca9c26 100644 --- a/src/components/file-upload/index.hbs +++ b/src/components/file-upload/index.hbs @@ -4,12 +4,34 @@ width: narrow tabs: true directory: file-upload intro: File uploaders help users to select and upload files. -model: - file-upload: ../../components/file-upload/json/file-upload.json meta-description: File uploaders help users to select and upload files. meta-index: true --- {{#>_docs-example}} -{{>_file-upload model.file-upload}} +
+
+ {{>_file-upload + id="file-upload-helper" + label="Upload drivers license" + helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" + required=false + }} +
+
+{{/_docs-example}} + +

Error messages

+{{#>_docs-example}} +
+
+ {{>_file-upload + id="file-upload-error" + label="Upload drivers license" + helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" + required=false + error-text="The selected file must be smaller than 350MB" + }} +
+
{{/_docs-example}} \ No newline at end of file diff --git a/src/components/file-upload/json/file-upload.json b/src/components/file-upload/json/file-upload.json deleted file mode 100644 index fade19e1..00000000 --- a/src/components/file-upload/json/file-upload.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "id": "nsw-file-upload" -} diff --git a/src/main.js b/src/main.js index 8de0c3cd..02c051e0 100644 --- a/src/main.js +++ b/src/main.js @@ -83,5 +83,5 @@ function initSite() { } export { - initSite, SiteSearch, Navigation, Accordion, Tabs, GlobalAlert, + initSite, SiteSearch, Navigation, Accordion, Tabs, GlobalAlert, Dialog, Filters, FileUpload, } From 81bc1c1bca1126ccf365bca26f3172df498c8a6f Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Wed, 8 Mar 2023 17:48:07 +1100 Subject: [PATCH 09/13] Update _file-upload.hbs --- src/components/file-upload/_file-upload.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs index 2df5e9c3..d53cc1ca 100644 --- a/src/components/file-upload/_file-upload.hbs +++ b/src/components/file-upload/_file-upload.hbs @@ -1,6 +1,6 @@ -{{#if helper-text}}{{{nl2br helper-text}}}{{/if}} +{{#if helper-text}}{{{helper-text}}}{{/if}} {{#if error-text}}{{error-text}}{{/if}} From 84e7dbe74e6d988ff647f7f712eef2eda8e644ab Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Thu, 9 Mar 2023 16:35:56 +1100 Subject: [PATCH 10/13] Implemented code review feedback [X] If/else functionality for accept, multiple and data-replace [X] Simplify classes [X] Add example of accept (on/off), multiple (on/off), data-replace (on/off) [X] Remove update label functionality [X] Fix label bug (opening file browser on label click) [X] Add focus with css (don't rely on JS for focus) --- src/components/file-upload/_file-upload.hbs | 15 ++--- src/components/file-upload/_file-upload.scss | 33 ++++++++-- src/components/file-upload/blank.hbs | 8 ++- src/components/file-upload/file-upload.js | 69 ++++---------------- src/components/file-upload/index.hbs | 7 +- 5 files changed, 54 insertions(+), 78 deletions(-) diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs index d53cc1ca..b1513be3 100644 --- a/src/components/file-upload/_file-upload.hbs +++ b/src/components/file-upload/_file-upload.hbs @@ -1,4 +1,4 @@ - + {{#if helper-text}}{{{helper-text}}}{{/if}} @@ -6,15 +6,14 @@ {{#if valid-text}}{{valid-text}}{{/if}} -
- - +
+ + +
  • - @@ -22,5 +21,3 @@
- - diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 321d2c97..9a50505a 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -1,27 +1,46 @@ .nsw-file-upload { + margin-top: rem(8px); display: block; &__label { margin: 0; - - &-content { - display: initial; - } } &__input { - display: none; + position: absolute; + opacity: 0; + + &:disabled { + + .nsw-file-upload__label { + cursor: not-allowed; + } + + + .nsw-file-upload__label { + opacity: 0.4; + cursor: not-allowed; + } + } + + &:focus + .nsw-file-upload__label { + @include nsw-focus; + } + + &[aria-invalid='true'] + .nsw-file-upload__label, + &.has-error + .nsw-file-upload__label { + border-color: var(--nsw-status-error); + border-width: 2px; + } } - &__list { + ul { margin: 0; padding: 0; border: 0; } &__item { - max-width: rem(500px); display: none; + max-width: rem(500px); align-items: center; justify-content: space-between; background-color: var(--nsw-off-white); diff --git a/src/components/file-upload/blank.hbs b/src/components/file-upload/blank.hbs index 34852e39..bd30828b 100644 --- a/src/components/file-upload/blank.hbs +++ b/src/components/file-upload/blank.hbs @@ -8,10 +8,13 @@ page: true
{{>_file-upload + js=true id="file-upload-helper" label="Upload drivers license" helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" - required=false + accept=true + multiple=true + data-replace=true }}
@@ -19,11 +22,12 @@ page: true
{{>_file-upload + js=true id="file-upload-error" label="Upload drivers license" helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" - required=false error-text="The selected file must be smaller than 350MB" + accept=true }}
diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 4b34dd4e..7254fe48 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -4,11 +4,9 @@ class FileUpload { this.element = element this.input = this.element.querySelector('.nsw-file-upload__input') this.label = this.element.querySelector('.nsw-file-upload__label') - this.labelText = this.element.querySelector('.nsw-file-upload__label-content') - this.initialLabel = this.labelText.textContent this.multipleUpload = this.input.hasAttribute('multiple') this.replaceFiles = this.element.hasAttribute('data-replace-files') - this.filesList = this.element.querySelector('.nsw-file-upload__list') + this.filesList = this.element.querySelector('ul') this.fileItems = false this.uploadedFiles = [] this.lastUploadedFiles = [] @@ -22,7 +20,7 @@ class FileUpload { initshowFiles() { if (this.filesList) { this.fileItems = this.filesList.querySelectorAll('.nsw-file-upload__item') - this.initRemoveFile() + this.removeFile() } } @@ -43,7 +41,7 @@ class FileUpload { this.input.addEventListener('change', () => { if (this.input.value === '') return this.storeUploadedFiles(this.input.files) - this.updateFileInput() + this.updateFileList() }) } @@ -52,34 +50,13 @@ class FileUpload { if (this.replaceFiles) this.uploadedFiles = [] Array.prototype.push.apply(this.lastUploadedFiles, fileData) this.uploadedFiles = this.uploadedFiles.concat(this.lastUploadedFiles) - this.updateInputLabelText(this.uploadedFiles) - } - - updateFileInput() { - this.updateFileList() - this.emitCustomEvents('filesUploaded', false) - } - - updateInputLabelText(uploadedFiles) { - let label = '' - if (uploadedFiles && uploadedFiles.length < 1) { - label = this.initialLabel - } else if (this.multipleUpload && uploadedFiles && uploadedFiles.length > 1) { - label = `${uploadedFiles.length} files` - } else { - for (let i = 0; i < uploadedFiles.length; i += 1) { - const { name } = uploadedFiles[i] - label = this.constructor.truncateString(name, 35) - } - } - this.labelText.textContent = label } updateFileList() { if (!this.fileItems || this.fileItems.length === 0) return const clone = this.fileItems[0].cloneNode(true) let string = '' - this.constructor.addClass(clone, 'active') + clone.classList.add('active') for (let i = 0; i < this.lastUploadedFiles.length; i += 1) { const { name } = this.lastUploadedFiles[i] clone.querySelectorAll('.nsw-file-upload__item-filename')[0].textContent = this.constructor.truncateString(name, 50) @@ -92,50 +69,26 @@ class FileUpload { } else { this.fileItems[0].insertAdjacentHTML('afterend', string) } - this.constructor.toggleClass(this.filesList, 'active', this.uploadedFiles.length === 0) + + if (this.uploadedFiles.length === 0) { + this.filesList.classList.toggle('active') + } } - initRemoveFile() { + removeFile() { this.filesList.addEventListener('click', (event) => { - if (!event.target.closest('.nsw-file-upload__item-button')) return + if (!event.target.closest('.nsw-icon-button')) return event.preventDefault() const item = event.target.closest('.nsw-file-upload__item') - const index = this.constructor.getIndexInArray(this.filesList.querySelectorAll('.nsw-file-upload__item'), item) - - const removedFile = this.uploadedFiles.splice(this.uploadedFiles.length - index, 1) - + const index = Array.prototype.indexOf.call(this.filesList.querySelectorAll('.nsw-file-upload__item'), item) const lastUploadedIndex = this.lastUploadedFiles.length - index if (lastUploadedIndex >= 0 && lastUploadedIndex < this.lastUploadedFiles.length - 1) { this.lastUploadedFiles.splice(this.lastUploadedFiles.length - index, 1) } item.remove() - this.emitCustomEvents('fileRemoved', removedFile) - this.updateInputLabelText(this.uploadedFiles) }) } - emitCustomEvents(eventName, detail) { - const event = new CustomEvent(eventName, { detail }) - this.element.dispatchEvent(event) - } - - static getIndexInArray(array, el) { - return Array.prototype.indexOf.call(array, el) - } - - static addClass(el, className) { - el.classList.add(className) - } - - static removeClass(el, className) { - el.classList.remove(className) - } - - static toggleClass(el, className, bool) { - if (bool) this.addClass(el, className) - else this.removeClass(el, className) - } - static truncateString(str, num) { if (str.length <= num) { return str diff --git a/src/components/file-upload/index.hbs b/src/components/file-upload/index.hbs index ccca9c26..16330c2d 100644 --- a/src/components/file-upload/index.hbs +++ b/src/components/file-upload/index.hbs @@ -12,10 +12,12 @@ meta-index: true
{{>_file-upload + js=true id="file-upload-helper" label="Upload drivers license" helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" - required=false + accept=true + multiple=true }}
@@ -26,11 +28,12 @@ meta-index: true
{{>_file-upload + js=true id="file-upload-error" label="Upload drivers license" helper-text="Formats accepted: JPG, PNG or PDF
File size must not exceed 350MB" - required=false error-text="The selected file must be smaller than 350MB" + accept=true }}
From d7b4239a1f9b76e49d945a0a90908fdf2cabdc1a Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Fri, 10 Mar 2023 11:12:56 +1100 Subject: [PATCH 11/13] Remove error styling --- src/components/file-upload/_file-upload.scss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 9a50505a..8aa34801 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -24,12 +24,6 @@ &:focus + .nsw-file-upload__label { @include nsw-focus; } - - &[aria-invalid='true'] + .nsw-file-upload__label, - &.has-error + .nsw-file-upload__label { - border-color: var(--nsw-status-error); - border-width: 2px; - } } ul { From 4f3408e115e95732a9cc10f85b8c334c581958ed Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Fri, 10 Mar 2023 11:23:01 +1100 Subject: [PATCH 12/13] Fix unnecessary accessible feature --- src/components/file-upload/_file-upload.hbs | 12 ++---------- src/components/file-upload/_file-upload.scss | 18 +++++++----------- src/components/file-upload/file-upload.js | 11 ----------- 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs index b1513be3..1a0e6b4a 100644 --- a/src/components/file-upload/_file-upload.hbs +++ b/src/components/file-upload/_file-upload.hbs @@ -1,15 +1,7 @@ - - -{{#if helper-text}}{{{helper-text}}}{{/if}} - -{{#if error-text}}{{error-text}}{{/if}} - -{{#if valid-text}}{{valid-text}}{{/if}} - -
+{{#if helper-text}}{{{helper-text}}}{{/if}}{{#if error-text}}{{error-text}}{{/if}}{{#if valid-text}}{{valid-text}}{{/if}} +
-
  • diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 8aa34801..1a3c9ea9 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -11,10 +11,6 @@ opacity: 0; &:disabled { - + .nsw-file-upload__label { - cursor: not-allowed; - } - + .nsw-file-upload__label { opacity: 0.4; cursor: not-allowed; @@ -45,15 +41,15 @@ margin-bottom: rem(8px); } - &-filename { - margin-left: rem(8px); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - &.active { display: flex; } } + + &__item-filename { + margin-left: rem(8px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 7254fe48..39daacf5 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -27,17 +27,6 @@ class FileUpload { initFileInput() { if (!this.input) return - this.label.setAttribute('tabindex', '0') - this.input.setAttribute('tabindex', '-1') - - this.input.addEventListener('focusin', () => { - this.label.focus() - }) - - this.label.addEventListener('keydown', (event) => { - if ((event.keyCode && event.keyCode === 13) || (event.key && event.key.toLowerCase() === 'enter')) { this.input.click() } - }) - this.input.addEventListener('change', () => { if (this.input.value === '') return this.storeUploadedFiles(this.input.files) From 99c4b33db647ec604b5226eef9f3258eed4645b5 Mon Sep 17 00:00:00 2001 From: Lauren Hitchon Date: Fri, 10 Mar 2023 16:46:43 +1100 Subject: [PATCH 13/13] Dynamically create list and simplify code --- src/components/file-upload/_file-upload.hbs | 15 ++-- src/components/file-upload/_file-upload.scss | 13 ++-- src/components/file-upload/blank.hbs | 2 +- src/components/file-upload/file-upload.js | 76 ++++++++++---------- 4 files changed, 49 insertions(+), 57 deletions(-) diff --git a/src/components/file-upload/_file-upload.hbs b/src/components/file-upload/_file-upload.hbs index 1a0e6b4a..0b033e7d 100644 --- a/src/components/file-upload/_file-upload.hbs +++ b/src/components/file-upload/_file-upload.hbs @@ -1,15 +1,8 @@ -{{#if helper-text}}{{{helper-text}}}{{/if}}{{#if error-text}}{{error-text}}{{/if}}{{#if valid-text}}{{valid-text}}{{/if}} +{{#if helper-text}} +{{{helper-text}}}{{/if}}{{#if error-text}} +{{error-text}}{{/if}}{{#if valid-text}} +{{valid-text}}{{/if}}
    -
      -
    • - - -
    • -
    - diff --git a/src/components/file-upload/_file-upload.scss b/src/components/file-upload/_file-upload.scss index 1a3c9ea9..f7290f24 100644 --- a/src/components/file-upload/_file-upload.scss +++ b/src/components/file-upload/_file-upload.scss @@ -22,14 +22,19 @@ } } - ul { + &__list { + display: none; margin: 0; padding: 0; border: 0; + + &.active { + display: block; + } } &__item { - display: none; + display: flex; max-width: rem(500px); align-items: center; justify-content: space-between; @@ -40,10 +45,6 @@ &:not(:last-child) { margin-bottom: rem(8px); } - - &.active { - display: flex; - } } &__item-filename { diff --git a/src/components/file-upload/blank.hbs b/src/components/file-upload/blank.hbs index bd30828b..3fbd5f3b 100644 --- a/src/components/file-upload/blank.hbs +++ b/src/components/file-upload/blank.hbs @@ -14,7 +14,7 @@ page: true helper-text="Formats accepted: JPG, PNG or PDF
    File size must not exceed 350MB" accept=true multiple=true - data-replace=true + replace=true }}
diff --git a/src/components/file-upload/file-upload.js b/src/components/file-upload/file-upload.js index 39daacf5..0206843d 100644 --- a/src/components/file-upload/file-upload.js +++ b/src/components/file-upload/file-upload.js @@ -6,62 +6,60 @@ class FileUpload { this.label = this.element.querySelector('.nsw-file-upload__label') this.multipleUpload = this.input.hasAttribute('multiple') this.replaceFiles = this.element.hasAttribute('data-replace-files') - this.filesList = this.element.querySelector('ul') - this.fileItems = false - this.uploadedFiles = [] - this.lastUploadedFiles = [] + this.filesList = false } init() { - this.initshowFiles() - this.initFileInput() - } - - initshowFiles() { - if (this.filesList) { - this.fileItems = this.filesList.querySelectorAll('.nsw-file-upload__item') - this.removeFile() - } - } - - initFileInput() { if (!this.input) return this.input.addEventListener('change', () => { if (this.input.value === '') return - this.storeUploadedFiles(this.input.files) this.updateFileList() }) } - storeUploadedFiles(fileData) { - this.lastUploadedFiles = [] - if (this.replaceFiles) this.uploadedFiles = [] - Array.prototype.push.apply(this.lastUploadedFiles, fileData) - this.uploadedFiles = this.uploadedFiles.concat(this.lastUploadedFiles) + createFileList() { + const ul = document.createElement('ul') + ul.classList.add('nsw-file-upload__list') + this.label.insertAdjacentElement('afterend', ul) + this.filesList = this.element.querySelector('.nsw-file-upload__list') + } + + createFileItem(file) { + const li = document.createElement('li') + li.classList.add('nsw-file-upload__item') + const html = ` + + ` + + li.insertAdjacentHTML('afterbegin', html) + li.querySelector('.nsw-file-upload__item-filename').textContent = this.constructor.truncateString(file.name, 50) + return li.outerHTML } updateFileList() { - if (!this.fileItems || this.fileItems.length === 0) return - const clone = this.fileItems[0].cloneNode(true) + if (!this.filesList) { + this.createFileList() + } + + this.filesList.classList.add('active') + let string = '' - clone.classList.add('active') - for (let i = 0; i < this.lastUploadedFiles.length; i += 1) { - const { name } = this.lastUploadedFiles[i] - clone.querySelectorAll('.nsw-file-upload__item-filename')[0].textContent = this.constructor.truncateString(name, 50) - string = clone.outerHTML + string + for (let i = 0; i < this.input.files.length; i += 1) { + const file = this.input.files[i] + string = this.createFileItem(file) + string } if (this.replaceFiles) { - string = this.fileItems[0].outerHTML + string this.filesList.innerHTML = string } else { - this.fileItems[0].insertAdjacentHTML('afterend', string) + this.filesList.insertAdjacentHTML('beforeend', string) } - if (this.uploadedFiles.length === 0) { - this.filesList.classList.toggle('active') - } + this.removeFile() } removeFile() { @@ -69,12 +67,12 @@ class FileUpload { if (!event.target.closest('.nsw-icon-button')) return event.preventDefault() const item = event.target.closest('.nsw-file-upload__item') - const index = Array.prototype.indexOf.call(this.filesList.querySelectorAll('.nsw-file-upload__item'), item) - const lastUploadedIndex = this.lastUploadedFiles.length - index - if (lastUploadedIndex >= 0 && lastUploadedIndex < this.lastUploadedFiles.length - 1) { - this.lastUploadedFiles.splice(this.lastUploadedFiles.length - index, 1) - } + item.remove() + + if (event.currentTarget.children.length === 0) { + this.filesList.classList.remove('active') + } }) }