diff --git a/.changeset/serious-trees-thank.md b/.changeset/serious-trees-thank.md
new file mode 100644
index 000000000..e383bc763
--- /dev/null
+++ b/.changeset/serious-trees-thank.md
@@ -0,0 +1,5 @@
+---
+'@lion/ui': minor
+---
+
+[input-file] Create input-file component
diff --git a/docs/blog/introducing-lion-ui.md b/docs/blog/introducing-lion-ui.md
index 4d5f3c2ef..eeac565bd 100644
--- a/docs/blog/introducing-lion-ui.md
+++ b/docs/blog/introducing-lion-ui.md
@@ -51,5 +51,5 @@ Support for this `exports` field was added to TypeScript 4.7 so for that reason,
### A new changelog
-`@lion/ui` has [a new single CHANGELOG.md]( https://github.com/ing-bank/lion/blob/master/packages/ui/CHANGELOG.md) in `/packages/ui` for the whole package. The older individual changelogs can be
+`@lion/ui` has [a new single CHANGELOG.md](https://github.com/ing-bank/lion/blob/master/packages/ui/CHANGELOG.md) in `/packages/ui` for the whole package. The older individual changelogs can be
found in the `/packages/ui/_legacy-changelogs` folder.
diff --git a/docs/components/input-file/index.md b/docs/components/input-file/index.md
new file mode 100644
index 000000000..25ee58a4f
--- /dev/null
+++ b/docs/components/input-file/index.md
@@ -0,0 +1,3 @@
+# Input File ||20
+
+-> go to Overview
diff --git a/docs/components/input-file/overview.md b/docs/components/input-file/overview.md
new file mode 100644
index 000000000..4c7ed506c
--- /dev/null
+++ b/docs/components/input-file/overview.md
@@ -0,0 +1,32 @@
+# Input File >> Overview ||10
+
+A web component based on the file input field.
+
+```js script
+import { html } from '@mdjs/mdjs-preview';
+import '@lion/ui/define/lion-input-file.js';
+```
+
+```js preview-story
+export const main = () => {
+ return html` `;
+};
+```
+
+## Features
+
+- Based on our [input](../input/overview.md)
+- Default labels and validation messages in different languages
+- Options for multi file upload and drop-zone.
+
+## Installation
+
+```bash
+npm i --save @lion/ui
+```
+
+```js
+import { LionInputFile } from '@lion/ui/input-file.js';
+// or
+import '@lion/ui/define/lion-input-file.js';
+```
diff --git a/docs/components/input-file/use-cases.md b/docs/components/input-file/use-cases.md
new file mode 100644
index 000000000..5692bfd47
--- /dev/null
+++ b/docs/components/input-file/use-cases.md
@@ -0,0 +1,425 @@
+# Input File >> Use Cases ||20
+
+```js script
+import { Required, Validator } from '@lion/ui/form-core.js';
+import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js';
+import { html } from '@mdjs/mdjs-preview';
+import { ScopedElementsMixin } from '@open-wc/scoped-elements';
+import { LitElement } from 'lit';
+import '@lion/ui/define/lion-input-file.js';
+loadDefaultFeedbackMessages();
+```
+
+## Features
+
+API calls to upload the selected files can be done in below two ways which is driven by `uploadOnSelect` property
+
+- On `form` submit
+- On file selection
+
+## Parameters
+
+```html
+
+
+```
+
+### modelValue
+
+Array of [File](https://developer.mozilla.org/en-US/docs/Web/API/File); Contains all the uploaded files
+
+### multiple
+
+Boolean; setting to `true` allows selecting multiple files.
+
+### EnableDragAndDrop
+
+Boolean; setting to `true` allows file upload through drag and drop.
+
+### uploadOnSelect
+
+Boolean;
+
+- Set to `true` when API calls for file upload needs to be done on file selection
+- Set to `false` when API calls for file upload needs to be done on form submit.
+- Default is `false`.
+
+### uploadResponse
+
+An array of the file-specific data to display the feedback of fileUpload
+
+Data structure for uploadResponse array:
+
+```js
+[
+ {
+ name: 'file1.txt', // name of uploaded file
+ status: '', // SUCCESS | FAIL | LOADING
+ errorMessage: '', // custom error message to be displayed
+ id: '', // unique id for future execution reference
+ },
+ {
+ name: 'file2.txt', // name of uploaded file
+ status: '', // SUCCESS | FAIL | LOADING
+ errorMessage: '', // custom error message to be displayed
+ id: '', // unique id for future execution reference
+ },
+];
+```
+
+## Events
+
+### model-value-changed
+
+Fired when modelValue property changes.
+
+### file-list-changed
+
+Fired when files are uploaded. Event `detail` gives list of newly added files in `newFiles` key
+
+```js
+ev.detail.newFiles;
+```
+
+### file-removed
+
+Fired when a file is removed. Event `detail` gives
+
+- File objected for removed file in `ev.detail.removedFile`
+- Status of this file can be used as `ev.detail.status`
+- uploadResponse for this file as set using `uploadResponse` property. Can use used as `ev.detail.uploadResponse`
+
+## Usage
+
+### Basic File upload
+
+When file has to be uploaded as soon as it is selected by the user. Use `file-list-changed` event to get the newly added file, upload it to your server and set the response back to component via `uploadResponse` property.
+
+```js preview-story
+export const basicFileUpload = () => {
+ return html`
+ {
+ console.log('fileList', ev.detail.newFiles);
+ }}"
+ >
+
+ `;
+};
+```
+
+### Validation
+
+#### Accept
+
+The `accept` attribute value is a string that defines the file types the file input should accept. This string is a comma-separated list of unique file type specifiers.
+
+For more info please consult the [MDN documentation for "accept"](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept).
+
+```js preview-story
+export const acceptValidator = () => {
+ return html`
+ {
+ console.log(ev.detail.newFiles);
+ }}"
+ >
+
+ `;
+};
+```
+
+#### Maximum File Size
+
+The `max-file-size` attribute sets the maximum file size in bytes.
+
+```js preview-story
+export const sizeValidator = () => {
+ return html`
+ {
+ console.log(ev.detail.newFiles);
+ }}"
+ >
+
+ `;
+};
+```
+
+### Multiple file upload
+
+When file has to be uploaded as soon as it is selected by the user. Use `file-list-changed` event to get the newly added files, upload it to your server and set the response back to component via `uploadResponse` property for each file.
+
+```js preview-story
+export const multipleFileUpload = () => {
+ return html`
+ {
+ console.log('removed file details', ev.detail);
+ }}"
+ @file-list-changed="${ev => {
+ console.log('file-list-changed', ev.detail.newFiles);
+ }}"
+ @model-value-changed="${ev => {
+ console.log('model-value-changed', ev);
+ }}"
+ >
+
+ `;
+};
+```
+
+### Pre-filled state
+
+The pre-filled state of the file upload component shows the files that were being assigned by the user initially.
+
+On every reload of page, the file upload component reverts to the initial state and using the `uploadResponse` property, shows the names(not links) of previously assigned files.
+
+**Note:**
+
+- the list of prefilled files will not be available in `modelValue` or `serializedValue`. This is because value cannot be assigned to native `input` with type `file`
+- Only `Required` validation (if set on `lion-file-input`) is triggered for files which are prefilled.
+
+```js preview-story
+export const prefilledState = () => {
+ return html`
+
+
+ `;
+};
+```
+
+### With form submit
+
+**Note:** When `lion-input-file` is prefilled, the list of prefilled files will not be available in `modelValue` or `serializedValue`. This is because value cannot be assigned to native `input` with type `file`.
+
+```js preview-story
+const myFormReset = ev => {
+ const input = ev.target.querySelector('lion-input-file');
+ input.reset();
+ console.log(input.modelValue);
+};
+
+const myFormSubmit = ev => {
+ ev.preventDefault();
+ const input = ev.target.querySelector('lion-input-file');
+ console.log(input.hasFeedbackFor);
+ console.log(input.serializedValue);
+ return false;
+};
+
+export const withIngForm = () => {
+ class FilenameLengthValidator extends Validator {
+ static get validatorName() {
+ return 'FilenameLengthValidator';
+ }
+
+ static getMessage(data) {
+ return `Filename length should not exceed ${data.params.maxFilenameLength} characters`;
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ checkFilenameLength(val, allowedFileNameLength) {
+ return val <= allowedFileNameLength;
+ }
+
+ execute(modelVal, { maxFilenameLength }) {
+ const invalidFileIndex = modelVal.findIndex(
+ file => !this.checkFilenameLength(file.name.length, maxFilenameLength),
+ );
+ return invalidFileIndex > -1;
+ }
+ }
+
+ return html`
+
+ `;
+};
+```
+
+### Without form submit
+
+Set `uploadOnSelect` property to `true`. This option can be used when Server API calls are needed as soon as it is selected by the user.
+
+For this scenario, the list of files is displayed based on the `uploadResponse` property which needs to maintained by the consumers of this component
+
+Below is the flow:
+
+#### When user uploads new file(s)
+
+1. `file-list-changed` event is fired from component with the newly added files
+2. Initiate the request to your backend API and set the status of the relevant files to `LOADING` in `uploadResponse` property
+3. Once the API request in completed, set the status of relevant files to `SUCCESS` or `FAIL` in `uploadResponse` property
+
+#### When user deletes a file
+
+1. `file-removed` event is fired from component with the deleted file
+2. Initiate the delete request to your backend API and set the status of the relevant files as `LOADING` in `uploadResponse` property
+3. Once the API request in completed, delete the file object from `uploadResponse` property
+
+```js preview-story
+export const uploadWithoutFormSubmit = () => {
+ return html`
+ {
+ ev.target.uploadResponse[
+ ev.target.uploadResponse.findIndex(file => file.name === ev.detail.uploadResponse.name)
+ ].status = 'LOADING';
+ ev.target.uploadResponse = [...ev.target.uploadResponse]; // update uploadResponse after API calls are completed
+ setTimeout(() => {
+ ev.target.uploadResponse[
+ ev.target.uploadResponse.findIndex(file => file.name === ev.detail.uploadResponse.name)
+ ] = {};
+ ev.target.uploadResponse = [...ev.target.uploadResponse]; // update uploadResponse after API calls are completed
+ }, 1000);
+ }}"
+ @file-list-changed="${ev => {
+ if (!ev.detail.newFiles[0]) {
+ return;
+ }
+ ev.target.uploadResponse[
+ ev.target.uploadResponse.findIndex(file => file.name === ev.detail.newFiles[0].name)
+ ].status = 'LOADING';
+ ev.target.uploadResponse = [...ev.target.uploadResponse]; // update uploadResponse after API calls are completed
+
+ if (ev.detail.newFiles[1]) {
+ ev.target.uploadResponse[
+ ev.target.uploadResponse.findIndex(file => file.name === ev.detail.newFiles[1].name)
+ ].status = 'LOADING';
+ ev.target.uploadResponse = [...ev.target.uploadResponse]; // update uploadResponse after API calls are completed
+ }
+ setTimeout(() => {
+ ev.target.uploadResponse[
+ ev.target.uploadResponse.findIndex(file => file.name === ev.detail.newFiles[0].name)
+ ].status = 'SUCCESS';
+ ev.target.uploadResponse = [...ev.target.uploadResponse]; // update uploadResponse after API calls are completed
+ }, 3000);
+
+ setTimeout(() => {
+ if (ev.detail.newFiles[1]) {
+ const file1Status = {
+ name: ev.detail.newFiles[1].name,
+ status: 'FAIL',
+ errorMessage: 'error from server',
+ };
+ ev.target.uploadResponse[
+ ev.target.uploadResponse.findIndex(file => file.name === ev.detail.newFiles[1].name)
+ ] = {
+ name: ev.detail.newFiles[1].name,
+ status: 'FAIL',
+ errorMessage: 'error from server',
+ };
+ ev.target.uploadResponse = [...ev.target.uploadResponse];
+ }
+ }, 3000);
+ }}"
+ >
+
+ `;
+};
+```
+
+### Drag and Drop
+
+Set the `enableDropZone` parameter to `true` to use the drag and drop functionality in the component.
+
+Drag and drop the files to be uploaded to the server.
+
+```js preview-story
+export const dragAndDrop = () => {
+ return html`
+
+
+ `;
+};
+```
+
+### Posting file to API using axios
+
+You can retrieve the uploaded files in `file-list-changed` event or from `modelValue` property of this component
+
+To submit files you can refer to the following code snippet:
+
+```js
+const formData = new FormData();
+const inputFile = document.querySelector('lion-input-file').modelValue;
+formData.append('file', inputFile.querySelector('input').files);
+axios.post('upload_file', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+});
+```
diff --git a/docs/fundamentals/systems/form/use-cases.md b/docs/fundamentals/systems/form/use-cases.md
index ea5225a83..c9dc04af3 100644
--- a/docs/fundamentals/systems/form/use-cases.md
+++ b/docs/fundamentals/systems/form/use-cases.md
@@ -16,6 +16,7 @@ import '@lion/ui/define/lion-input-amount.js';
import '@lion/ui/define/lion-input-date.js';
import '@lion/ui/define/lion-input-datepicker.js';
import '@lion/ui/define/lion-input-email.js';
+import '@lion/ui/define/lion-input-file.js';
import '@lion/ui/define/lion-input-tel.js';
import '@lion/ui/define/lion-input-tel-dropdown.js';
import '@lion/ui/define/lion-input-iban.js';
@@ -75,6 +76,7 @@ export const main = () => {
+
* Here we rerender slots defined with a `SlotRerenderObject`
* @param {import('lit-element').PropertyValues } changedProperties
*/
- updated(changedProperties) {
- super.updated(changedProperties);
+ update(changedProperties) {
+ super.update(changedProperties);
if (this.__slotsThatNeedRerender.size) {
for (const slotName of Array.from(this.__slotsThatNeedRerender)) {
diff --git a/packages/ui/components/form-core/src/FocusMixin.js b/packages/ui/components/form-core/src/FocusMixin.js
index a1102cdaf..03b905a17 100644
--- a/packages/ui/components/form-core/src/FocusMixin.js
+++ b/packages/ui/components/form-core/src/FocusMixin.js
@@ -51,8 +51,11 @@ const FocusMixinImplementation = superclass =>
this.autofocus = false;
}
- connectedCallback() {
- super.connectedCallback();
+ /**
+ * @param {import('lit').PropertyValues } changedProperties
+ */
+ firstUpdated(changedProperties) {
+ super.firstUpdated(changedProperties);
this.__registerEventsForFocusMixin();
this.__syncAutofocusToFocusableElement();
}
diff --git a/packages/ui/components/form-core/src/LionField.js b/packages/ui/components/form-core/src/LionField.js
index f7f2722ae..f14702858 100644
--- a/packages/ui/components/form-core/src/LionField.js
+++ b/packages/ui/components/form-core/src/LionField.js
@@ -72,9 +72,11 @@ export class LionField extends FormControlMixin(
/**
* Dispatches custom bubble event
+ * @param {Event=} ev
* @protected
*/
- _onChange() {
+ // eslint-disable-next-line no-unused-vars
+ _onChange(ev) {
/** @protectedEvent user-input-changed */
this.dispatchEvent(new Event('user-input-changed', { bubbles: true }));
}
diff --git a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js
index 4ff8ca534..290f4758b 100644
--- a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js
+++ b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js
@@ -3,10 +3,7 @@ import { html, LitElement } from 'lit';
/**
* @typedef {import('./Validator.js').Validator} Validator
* @typedef {import('lit').TemplateResult} TemplateResult
- * @typedef {Object} messageMap
- * @property {string | Node} message
- * @property {string} type
- * @property {Validator} [validator]
+ * @typedef {import('../../types/validate/ValidateMixinTypes.js').FeedbackMessage} FeedbackMessage
*/
/**
@@ -47,7 +44,7 @@ export class LionValidationFeedback extends LitElement {
if (this.currentType === 'success') {
this.removeMessage = window.setTimeout(() => {
this.removeAttribute('type');
- /** @type {messageMap[]} */
+ /** @type {FeedbackMessage[]} */
this.feedbackData = [];
}, 3000);
}
diff --git a/packages/ui/components/form-core/src/validate/ResultValidator.js b/packages/ui/components/form-core/src/validate/ResultValidator.js
index 4588e771b..875d892d0 100644
--- a/packages/ui/components/form-core/src/validate/ResultValidator.js
+++ b/packages/ui/components/form-core/src/validate/ResultValidator.js
@@ -1,7 +1,7 @@
import { Validator } from './Validator.js';
/**
- * @desc Instead of evaluating the result of a regular validator, a ResultValidator looks
+ * Instead of evaluating the result of a regular Validator, a ResultValidator looks
* at the total result of regular Validators. Instead of an execute function, it uses a
* 'executeOnResults' Validator.
* ResultValidators cannot be async, and should not contain an execute method.
diff --git a/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts b/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts
index 4de5d08a1..c7fb36d3d 100644
--- a/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts
+++ b/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts
@@ -9,9 +9,9 @@ import { FormControlHost } from '../FormControlMixinTypes.js';
import { SyncUpdatableHost } from '../utils/SyncUpdatableMixinTypes.js';
import { Validator } from '../../src/validate/Validator.js';
-type FeedbackMessage = {
+export type FeedbackMessage = {
message: string | Node;
- type: string;
+ type: ValidationType | string;
validator?: Validator;
};
diff --git a/packages/ui/components/form-integrations/test/dialog-integrations.test.js b/packages/ui/components/form-integrations/test/dialog-integrations.test.js
index 393da46d6..fb4758c4d 100644
--- a/packages/ui/components/form-integrations/test/dialog-integrations.test.js
+++ b/packages/ui/components/form-integrations/test/dialog-integrations.test.js
@@ -37,6 +37,7 @@ describe('Form inside dialog Integrations', () => {
'lion-input-amount',
'lion-input-iban',
'lion-input-email',
+ 'lion-input-file',
'lion-input-tel',
'lion-input-tel-dropdown',
'lion-checkbox-group',
diff --git a/packages/ui/components/form-integrations/test/form-group-methods.test.js b/packages/ui/components/form-integrations/test/form-group-methods.test.js
index e82830809..2ec6b8c1f 100644
--- a/packages/ui/components/form-integrations/test/form-group-methods.test.js
+++ b/packages/ui/components/form-integrations/test/form-group-methods.test.js
@@ -1,49 +1,20 @@
import { elementUpdated, expect, fixture } from '@open-wc/testing';
import { html } from 'lit';
-import './helpers/umbrella-form.js';
import { getAllFieldsAndFormGroups } from './helpers/helpers.js';
-
-import '@lion/ui/define/lion-button.js';
-import '@lion/ui/define/lion-button-submit.js';
-import '@lion/ui/define/lion-button-reset.js';
-
-import '@lion/ui/define/lion-checkbox.js';
-import '@lion/ui/define/lion-checkbox-group.js';
-import '@lion/ui/define/lion-combobox.js';
-
-import '@lion/ui/define/lion-dialog.js';
-
-import '@lion/ui/define/lion-form.js';
-import '@lion/ui/define/lion-fieldset.js';
-import '@lion/ui/define/lion-select-rich.js';
-import '@lion/ui/define/lion-select.js';
-
-import '@lion/ui/define/lion-input-stepper.js';
-import '@lion/ui/define/lion-input-range.js';
-import '@lion/ui/define/lion-input-amount.js';
-import '@lion/ui/define/lion-input.js';
-import '@lion/ui/define/lion-input-date.js';
-import '@lion/ui/define/lion-input-iban.js';
-import '@lion/ui/define/lion-input-email.js';
-import '@lion/ui/define/lion-input-tel-dropdown.js';
-import '@lion/ui/define/lion-input-datepicker.js';
-
-import '@lion/ui/define/lion-textarea.js';
-
-import '@lion/ui/define/lion-radio.js';
-import '@lion/ui/define/lion-radio-group.js';
-
-import '@lion/ui/define/lion-listbox.js';
-import '@lion/ui/define/lion-option.js';
-import '@lion/ui/define/lion-options.js';
-
-import '@lion/ui/define/lion-validation-feedback.js';
+import './helpers/umbrella-form.js';
/**
* @typedef {import('../../form-core/src/LionField.js').LionField} LionField
+ * @typedef {import('../../input-file/types/input-file.js').InputFile} InputFile
* @typedef {import('../../button/src/LionButton.js').LionButton} LionButton
* @typedef {import('./helpers/umbrella-form.js').UmbrellaForm} UmbrellaForm
*/
+const file = /** @type {InputFile} */ (
+ new File(['foo'], 'foo.txt', {
+ type: 'text/plain',
+ })
+);
+
const fullyPrefilledSerializedValue = {
fullName: { firstName: 'Lorem', lastName: 'Ipsum' },
date: '2000-12-12',
@@ -57,6 +28,7 @@ const fullyPrefilledSerializedValue = {
favoriteFruit: 'Banana',
favoriteMovie: 'Rocky',
favoriteColor: 'hotpink',
+ file: [],
lyrics: '1',
notifications: {
checked: true,
@@ -81,6 +53,7 @@ const fullyChangedSerializedValue = {
favoriteFruit: '',
favoriteMovie: '',
favoriteColor: '',
+ file: [file],
lyrics: '2',
notifications: {
checked: false,
@@ -189,6 +162,7 @@ describe(`Submitting/Resetting/Clearing Form`, async () => {
favoriteFruit: '',
favoriteMovie: '',
favoriteColor: '',
+ file: [],
lyrics: '',
notifications: {
checked: false,
diff --git a/packages/ui/components/form-integrations/test/form-integrations.test.js b/packages/ui/components/form-integrations/test/form-integrations.test.js
index ea4e459d7..8739192e6 100644
--- a/packages/ui/components/form-integrations/test/form-integrations.test.js
+++ b/packages/ui/components/form-integrations/test/form-integrations.test.js
@@ -41,6 +41,7 @@ describe('Form Integrations', () => {
favoriteFruit: 'Banana',
favoriteMovie: 'Rocky',
favoriteColor: 'hotpink',
+ file: [],
lyrics: '1',
range: 2.3,
terms: [],
@@ -74,6 +75,7 @@ describe('Form Integrations', () => {
favoriteFruit: 'Banana',
favoriteMovie: 'Rocky',
favoriteColor: 'hotpink',
+ file: '',
lyrics: 'Fire up that loud',
range: 2.3,
terms: [],
@@ -98,6 +100,7 @@ describe('Form Integrations', () => {
money: '',
iban: '',
email: '',
+ file: [],
checkers: ['foo', 'bar'],
dinosaurs: 'brontosaurus',
favoriteFruit: 'Banana',
@@ -129,6 +132,7 @@ describe('Form Integrations', () => {
'lion-input-amount',
'lion-input-iban',
'lion-input-email',
+ 'lion-input-file',
'lion-input-tel',
'lion-input-tel-dropdown',
'lion-checkbox-group',
diff --git a/packages/ui/components/form-integrations/test/helpers/umbrella-form.js b/packages/ui/components/form-integrations/test/helpers/umbrella-form.js
index 303a4b25b..8974503ac 100644
--- a/packages/ui/components/form-integrations/test/helpers/umbrella-form.js
+++ b/packages/ui/components/form-integrations/test/helpers/umbrella-form.js
@@ -1,29 +1,31 @@
-import { LitElement, html } from 'lit';
-import { Required, MinLength } from '@lion/ui/form-core.js';
-import '@lion/ui/define/lion-form.js';
-import '@lion/ui/define/lion-fieldset.js';
-import '@lion/ui/define/lion-input.js';
-import '@lion/ui/define/lion-input-date.js';
-import '@lion/ui/define/lion-input-datepicker.js';
-import '@lion/ui/define/lion-input-amount.js';
-import '@lion/ui/define/lion-input-iban.js';
-import '@lion/ui/define/lion-input-email.js';
-import '@lion/ui/define/lion-input-tel.js';
-import '@lion/ui/define/lion-input-tel-dropdown.js';
+import '@lion/ui/define/lion-button-reset.js';
+import '@lion/ui/define/lion-button-submit.js';
import '@lion/ui/define/lion-checkbox-group.js';
import '@lion/ui/define/lion-checkbox.js';
-import '@lion/ui/define/lion-radio-group.js';
-import '@lion/ui/define/lion-radio.js';
-import '@lion/ui/define/lion-select.js';
-import '@lion/ui/define/lion-select-rich.js';
+import '@lion/ui/define/lion-combobox.js';
+import '@lion/ui/define/lion-fieldset.js';
+import '@lion/ui/define/lion-form.js';
+import '@lion/ui/define/lion-input-amount.js';
+import '@lion/ui/define/lion-input-date.js';
+import '@lion/ui/define/lion-input-datepicker.js';
+import '@lion/ui/define/lion-input-email.js';
+import '@lion/ui/define/lion-input-file.js';
+import '@lion/ui/define/lion-input-iban.js';
+import '@lion/ui/define/lion-input-range.js';
+import '@lion/ui/define/lion-input-stepper.js';
+import '@lion/ui/define/lion-input-tel-dropdown.js';
+import '@lion/ui/define/lion-input-tel.js';
+import '@lion/ui/define/lion-input.js';
import '@lion/ui/define/lion-listbox.js';
import '@lion/ui/define/lion-option.js';
-import '@lion/ui/define/lion-combobox.js';
-import '@lion/ui/define/lion-input-range.js';
-import '@lion/ui/define/lion-textarea.js';
-import '@lion/ui/define/lion-button.js';
+import '@lion/ui/define/lion-radio-group.js';
+import '@lion/ui/define/lion-radio.js';
+import '@lion/ui/define/lion-select-rich.js';
+import '@lion/ui/define/lion-select.js';
import '@lion/ui/define/lion-switch.js';
-import '@lion/ui/define/lion-input-stepper.js';
+import '@lion/ui/define/lion-textarea.js';
+import { MinLength, Required } from '@lion/ui/form-core.js';
+import { html, LitElement } from 'lit';
export class UmbrellaForm extends LitElement {
get _lionFormNode() {
@@ -83,6 +85,7 @@ export class UmbrellaForm extends LitElement {
+
diff --git a/packages/ui/components/input-file/src/FileHandle.js b/packages/ui/components/input-file/src/FileHandle.js
new file mode 100644
index 000000000..596407b55
--- /dev/null
+++ b/packages/ui/components/input-file/src/FileHandle.js
@@ -0,0 +1,98 @@
+import { IsAcceptedFile } from './validators.js';
+
+/**
+ * @typedef {import('../types/input-file.js').SystemFile} SystemFile
+ */
+
+// Do these global constants add value? They are only used in this file
+// Typing filehandle would be enough
+
+/**
+ * 500MB in bytes
+ */
+export const MAX_FILE_SIZE = 524288000;
+
+/**
+ * @typedef {Object} property
+ * @property {string} type
+ * @property {string} size
+ */
+export const FILE_FAILED_PROP = {
+ type: 'FILE_TYPE',
+ size: 'FILE_SIZE',
+};
+
+export const UPLOAD_FILE_STATUS = {
+ fail: 'FAIL',
+ pass: 'SUCCESS',
+};
+
+export class FileHandle {
+ /**
+ * @param {SystemFile} systemFile
+ * @param {{ allowedFileTypes: Array; allowedFileExtensions: Array; maxFileSize: number; }} _acceptCriteria
+ */
+ constructor(systemFile, _acceptCriteria) {
+ /**
+ * @type {Array}
+ */
+ this.failedProp = [];
+ this.systemFile = systemFile;
+ this._acceptCriteria = _acceptCriteria;
+ this.uploadFileStatus();
+ if (this.failedProp.length === 0) {
+ this.createDownloadUrl(systemFile);
+ }
+ }
+
+ // TDOO: same util as in validators.js
+ /**
+ * @param {string} fileName
+ * @return {string}
+ * @protected
+ */
+ // eslint-disable-next-line class-methods-use-this
+ _getFileNameExtension(fileName) {
+ return fileName.slice(fileName.lastIndexOf('.') + 1);
+ }
+
+ // TODO: seems to suggest upload is going on...
+ // checks the file size and type to set failedProp property
+ uploadFileStatus() {
+ if (this._acceptCriteria.allowedFileExtensions.length) {
+ const fileExtension = this._getFileNameExtension(this.systemFile.name);
+ if (
+ !IsAcceptedFile.isExtensionAllowed(
+ fileExtension,
+ this._acceptCriteria.allowedFileExtensions,
+ )
+ ) {
+ this.status = UPLOAD_FILE_STATUS.fail;
+ this.failedProp.push(FILE_FAILED_PROP.type);
+ }
+ } else if (this._acceptCriteria.allowedFileTypes.length) {
+ const fileType = this.systemFile.type;
+ if (!IsAcceptedFile.isFileTypeAllowed(fileType, this._acceptCriteria.allowedFileTypes)) {
+ this.status = UPLOAD_FILE_STATUS.fail;
+ this.failedProp.push(FILE_FAILED_PROP.type);
+ }
+ }
+
+ if (IsAcceptedFile.checkFileSize(this.systemFile.size, this._acceptCriteria.maxFileSize)) {
+ if (this.status !== UPLOAD_FILE_STATUS.fail) {
+ this.status = UPLOAD_FILE_STATUS.pass;
+ }
+ } else {
+ this.status = UPLOAD_FILE_STATUS.fail;
+ this.failedProp.push(FILE_FAILED_PROP.size);
+ }
+ }
+
+ /**
+ * @param {SystemFile} file
+ */
+ createDownloadUrl(file) {
+ // @ts-ignore
+ this.downloadUrl = window.URL.createObjectURL(file);
+ }
+}
diff --git a/packages/ui/components/input-file/src/LionInputFile.js b/packages/ui/components/input-file/src/LionInputFile.js
new file mode 100644
index 000000000..db53e5dbf
--- /dev/null
+++ b/packages/ui/components/input-file/src/LionInputFile.js
@@ -0,0 +1,892 @@
+import { LionField } from '@lion/ui/form-core.js';
+import { LocalizeMixin } from '@lion/ui/localize.js';
+import { ScopedElementsMixin } from '@open-wc/scoped-elements';
+import { css, html } from 'lit';
+import { ifDefined } from 'lit/directives/if-defined.js';
+import { FileHandle, MAX_FILE_SIZE } from './FileHandle.js';
+import { LionSelectedFileList } from './LionSelectedFileList.js';
+import { localizeNamespaceLoader } from './localizeNamespaceLoader.js';
+import { DuplicateFileNames, IsAcceptedFile } from './validators.js';
+
+/**
+ * @typedef {import('lit').TemplateResult} TemplateResult
+ * @typedef {import('lit').RenderOptions} RenderOptions
+ * @typedef {import('../types/input-file.js').InputFile} InputFile
+ * @typedef {import('../types/input-file.js').SystemFile} SystemFile
+ * @typedef {import('../types/input-file.js').FileSelectResponse} FileSelectResponse
+ */
+
+/**
+ * @param {number} bytes
+ * @param {number} decimals
+ */
+function formatBytes(bytes, decimals = 2) {
+ if (!+bytes) {
+ return '0 Bytes';
+ }
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = [' bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / k ** i).toFixed(dm))}${sizes[i]}`;
+}
+
+export class LionInputFile extends ScopedElementsMixin(LocalizeMixin(LionField)) {
+ static get scopedElements() {
+ return {
+ ...super.scopedElements,
+ 'lion-selected-file-list': LionSelectedFileList,
+ };
+ }
+
+ static get properties() {
+ return {
+ accept: { type: String },
+ multiple: { type: Boolean, reflect: true },
+ buttonLabel: { type: String, attribute: 'button-label' },
+ maxFileSize: { type: Number, attribute: 'max-file-size' },
+ enableDropZone: { type: Boolean, attribute: 'enable-drop-zone' },
+ uploadOnSelect: { type: Boolean, attribute: 'upload-on-select' },
+ _fileSelectResponse: { type: Array, state: false },
+ _selectedFilesMetaData: { type: Array, state: true },
+ };
+ }
+
+ static localizeNamespaces = [
+ { 'lion-input-file': localizeNamespaceLoader },
+ ...super.localizeNamespaces,
+ ];
+
+ static get validationTypes() {
+ return ['error', 'info'];
+ }
+
+ /**
+ * @configure SlotMixin
+ */
+ get slots() {
+ return {
+ ...super.slots,
+ input: () => html``,
+ 'file-select-button': () =>
+ html``,
+ 'selected-file-list': () => ({
+ template: html`
+
+ `,
+ }),
+ };
+ }
+
+ /**
+ * @type {HTMLInputElement}
+ * @protected
+ */
+ get _inputNode() {
+ return /** @type {HTMLInputElement} */ (super._inputNode);
+ }
+
+ /**
+ * @protected
+ */
+ get _buttonNode() {
+ return this.querySelector(`#select-button-${this._inputId}`);
+ }
+
+ /**
+ * The helpt text for the input node.
+ * When no light dom defined via [slot=help-text], this value will be used
+ * @type {string}
+ */
+ get buttonLabel() {
+ return this.__buttonLabel || this._buttonNode?.textContent || '';
+ }
+
+ /**
+ * @param {string} newValue
+ */
+ set buttonLabel(newValue) {
+ const oldValue = this.buttonLabel;
+ /** @type {string} */
+ this.__buttonLabel = newValue;
+ this.requestUpdate('buttonLabel', oldValue);
+ }
+
+ /**
+ * @protected
+ * @configure FocusMixin
+ */
+ // @ts-ignore
+ get _focusableNode() {
+ return this._buttonNode;
+ }
+
+ /**
+ * @protected
+ */
+ // TODO: no need to check anymore? https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable
+ // eslint-disable-next-line class-methods-use-this
+ get _isDragAndDropSupported() {
+ return 'draggable' in document.createElement('div');
+ }
+
+ constructor() {
+ super();
+ this.type = 'file';
+ /**
+ * @protected
+ * @type {InputFile[]}
+ */
+ this._selectedFilesMetaData = [];
+ /**
+ * @type {FileSelectResponse[]}
+ */
+ // TODO: make readonly?
+ this._fileSelectResponse = [];
+ /**
+ * @private
+ */
+ this.__initialFileSelectResponse = this._fileSelectResponse;
+ // TODO: public default booleans are always false
+ this.uploadOnSelect = false;
+ this.multiple = false;
+ this.enableDropZone = false;
+ this.maxFileSize = MAX_FILE_SIZE;
+ this.accept = '';
+ /**
+ * @type {InputFile[]}
+ */
+ this.modelValue = [];
+ /**
+ * @protected
+ * @type {EventListener}
+ */
+ this._onRemoveFile = this._onRemoveFile.bind(this);
+
+ /** @private */
+ this.__duplicateFileNamesValidator = new DuplicateFileNames({ show: false });
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.__initialFileSelectResponse = this._fileSelectResponse;
+
+ this._inputNode.addEventListener('change', this._onChange);
+ this._inputNode.addEventListener('click', this._onClick);
+ this.addEventListener(
+ 'file-remove-requested',
+ /** @type {EventListener} */ (this._onRemoveFile),
+ );
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._inputNode.removeEventListener('change', this._onChange);
+ this._inputNode.removeEventListener('click', this._onClick);
+ this.removeEventListener(
+ 'file-remove-requested',
+ /** @type {EventListener} */ (this._onRemoveFile),
+ );
+ }
+
+ /**
+ * @configure LocalizeMixin
+ */
+ onLocaleUpdated() {
+ super.onLocaleUpdated();
+ if (this.multiple) {
+ // @ts-ignore
+ this.buttonLabel = this.msgLit('lion-input-file:selectTextMultipleFile');
+ } else {
+ // @ts-ignore
+ this.buttonLabel = this.msgLit('lion-input-file:selectTextSingleFile');
+ }
+ }
+
+ get _acceptCriteria() {
+ /** @type {string[]} */
+ let allowedFileTypes = [];
+ /** @type {string[]} */
+ let allowedFileExtensions = [];
+ if (this.accept) {
+ const acceptedFiles = this.accept.replace(/\s+/g, '').replace(/\.+/g, '').split(',');
+ allowedFileTypes = acceptedFiles.filter(acceptedFile => acceptedFile.includes('/'));
+ allowedFileExtensions = acceptedFiles.filter(acceptedFile => !acceptedFile.includes('/'));
+ }
+ return {
+ allowedFileTypes,
+ allowedFileExtensions,
+ maxFileSize: this.maxFileSize,
+ };
+ }
+
+ /**
+ * @enhance LionField
+ * Resets modelValue to initial value.
+ * Interaction states are cleared
+ */
+ reset() {
+ super.reset();
+ this._selectedFilesMetaData = [];
+ this._fileSelectResponse = this.__initialFileSelectResponse;
+ this.modelValue = [];
+ // TODO: find out why it stays dirty
+ this.dirty = false;
+ }
+
+ /**
+ * Clears modelValue.
+ * Interaction states are not cleared (use resetInteractionState for this)
+ * @override LionField
+ */
+ clear() {
+ this._selectedFilesMetaData = [];
+ this._fileSelectResponse = [];
+ this.modelValue = [];
+ }
+
+ /**
+ * @override ValidateMixin: override to hide the IsAcceptedFile feedback at component level as they are displayed at each file level in file list
+ * @param {string} type could be 'error', 'warning', 'info', 'success' or any other custom
+ * @param {object} meta meta info (interaction states etc)
+ * @protected
+ */
+ _showFeedbackConditionFor(type, meta) {
+ return (
+ super._showFeedbackConditionFor(type, meta) &&
+ !(
+ this.validationStates.error?.FileTypeAllowed || this.validationStates.error?.FileSizeAllowed
+ )
+ );
+ }
+
+ /**
+ * @configure FormatMixin
+ * @returns {InputFile[]} parsedValue
+ */
+ parser() {
+ // @ts-ignore
+ return this._inputNode.files ? Array.from(this._inputNode.files) : [];
+ }
+
+ /**
+ * @configure FormatMixin
+ * @param {InputFile[]} v - modelValue: File[]
+ * @returns {string} formattedValue
+ */
+ // eslint-disable-next-line no-unused-vars
+ formatter(v) {
+ return this._inputNode?.value || '';
+ }
+
+ /** @private */
+ __setupDragDropEventListeners() {
+ // TODO: this will break as soon as a Subclasser changes the template ... (changing class names is allowed, ids should be kept)
+ const dropZone = this.shadowRoot?.querySelector('.input-file__drop-zone');
+ ['dragenter', 'dragover', 'dragleave'].forEach(eventName => {
+ dropZone?.addEventListener(
+ eventName,
+ (/** @type {Event} */ ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (eventName !== 'dragleave') {
+ this.setAttribute('is-dragging', '');
+ } else {
+ this.removeAttribute('is-dragging');
+ }
+ },
+ false,
+ );
+ });
+
+ window.addEventListener(
+ 'drop',
+ ev => {
+ if (ev.target === this._inputNode) {
+ ev.preventDefault();
+ }
+ this.removeAttribute('is-dragging');
+ },
+ false,
+ );
+ }
+
+ /**
+ * @param {import('lit').PropertyValues } changedProperties
+ */
+ firstUpdated(changedProperties) {
+ super.firstUpdated(changedProperties);
+
+ this.__setupFileValidators();
+ // We need to update our light dom
+ this._enhanceSelectedList();
+
+ // TODO: if there's no inputNode by now, we should either throw an error or add a slot change listener
+ if (this._inputNode) {
+ // TODO: should it be possible to change this on the fly (in updated)?
+ this._inputNode.type = this.type;
+ this._inputNode.setAttribute('tabindex', '-1');
+ // TODO: should it be possible to change this on the fly (in updated)?
+ this._inputNode.multiple = this.multiple;
+ if (this.accept.length) {
+ // TODO: should it be possible to change this on the fly (in updated)?
+ this._inputNode.accept = this.accept;
+ }
+ }
+
+ if (this.enableDropZone && this._isDragAndDropSupported) {
+ this.__setupDragDropEventListeners();
+ this.setAttribute('drop-zone', '');
+ }
+ }
+
+ /**
+ * @param {import('lit').PropertyValues } changedProperties
+ */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ // TODO: mention code originates from LionInput, but we could not extend from it bc/o x/y/z
+ if (changedProperties.has('disabled')) {
+ this._inputNode.disabled = this.disabled;
+ this.validate();
+ }
+
+ if (changedProperties.has('buttonLabel') && this._buttonNode) {
+ this._buttonNode.textContent = this.buttonLabel;
+ }
+
+ // TODO: mention code originates from LionInput, but we could not extend from it bc/o x/y/z
+ if (changedProperties.has('name')) {
+ this._inputNode.name = this.name;
+ }
+
+ if (changedProperties.has('_ariaLabelledNodes')) {
+ this.__syncAriaLabelledByAttributesToButton();
+ }
+
+ if (changedProperties.has('_ariaDescribedNodes')) {
+ this.__syncAriaDescribedByAttributesToButton();
+ }
+
+ /**
+ * Update _selectedFilesMetaData only if:
+ * 1. It is invoked from the file-removed event handler.
+ * 2. There is a mismatch between the selected files and files on UI.
+ */
+ if (changedProperties.has('_fileSelectResponse')) {
+ if (this._selectedFilesMetaData.length === 0) {
+ this._fileSelectResponse.forEach(preResponse => {
+ const file = {
+ systemFile: {
+ name: preResponse.name,
+ },
+ response: preResponse,
+ status: preResponse.status,
+ validationFeedback: [
+ {
+ message: preResponse.errorMessage,
+ },
+ ],
+ };
+ // @ts-ignore
+ this._selectedFilesMetaData = [...this._selectedFilesMetaData, file];
+ });
+ }
+ this._selectedFilesMetaData.forEach(file => {
+ if (
+ !this._fileSelectResponse.some(response => response.name === file.systemFile.name) &&
+ this.uploadOnSelect
+ ) {
+ this.__removeFileFromList(file);
+ } else {
+ this._fileSelectResponse.forEach(response => {
+ if (response.name === file.systemFile.name) {
+ // eslint-disable-next-line no-param-reassign
+ file.response = response;
+ // eslint-disable-next-line no-param-reassign
+ file.downloadUrl = response.downloadUrl ? response.downloadUrl : file.downloadUrl;
+ // eslint-disable-next-line no-param-reassign
+ file.status = response.status;
+ // eslint-disable-next-line no-param-reassign
+ file.validationFeedback = [
+ {
+ type: response.errorMessage?.length > 0 ? 'error' : 'success',
+ message: response.errorMessage,
+ },
+ ];
+ }
+ });
+ // this._selectedFilesMetaData = [...this._selectedFilesMetaData];
+ }
+ });
+ }
+ }
+
+ // TODO: this method also triggers a validator...
+ /**
+ * @private
+ * @param {InputFile[]} fileList
+ */
+ __computeNewAddedFiles(fileList) {
+ const computedFileList = fileList.filter(
+ file =>
+ this._selectedFilesMetaData.findIndex(
+ existLionFile => existLionFile.systemFile.name === file.name,
+ ) === -1,
+ );
+ // TODO: put this logic in the Validator itself. Changing the param should trigger a re-validate
+ this.__duplicateFileNamesValidator.param = {
+ show: fileList.length !== computedFileList.length,
+ };
+ this.validate();
+
+ return computedFileList;
+ }
+
+ /**
+ * @param {DragEvent} ev
+ * @protected
+ */
+ _processDroppedFiles(ev) {
+ ev.preventDefault();
+ this.removeAttribute('is-dragging');
+
+ const isDraggingMultipleWhileNotSupported =
+ ev.dataTransfer && ev.dataTransfer.items.length > 1 && !this.multiple;
+ if (isDraggingMultipleWhileNotSupported || !ev.dataTransfer?.files) {
+ return;
+ }
+
+ this._inputNode.files = ev.dataTransfer.files;
+ // @ts-ignore
+ this.modelValue = Array.from(ev.dataTransfer.files);
+ // if same file is selected again, e.dataTransfer.files lists that file.
+ // So filter if the file already exists
+ // @ts-ignore
+ // const newFiles = this.__computeNewAddedFiles(Array.from(ev.dataTransfer.files));
+ // if (newFiles.length > 0) {
+ // this._processFiles(newFiles);
+ // }
+ this._processFiles(Array.from(ev.dataTransfer.files));
+ }
+
+ /**
+ * @override
+ * @param {Event=} ev
+ * @protected
+ */
+
+ _onChange(ev) {
+ // Here, we take over the responsibility of InteractionStateMixin, as _leaveEvent is not the best trigger in this case.
+ // Giving feedback right after the file dialog is closed results in best UX.
+ this.touched = true;
+ // Here, we connect ourselves to the FormatMixin flow...
+ // TODO: should we call super._onChange(ev) here instead?
+ this._onUserInputChanged();
+ this._processFiles(/** @type {HTMLInputElement & {files:InputFile[]}} */ (ev?.target)?.files);
+ }
+
+ /**
+ * Clear _inputNode.value to make sure onChange is called even for duplicate files
+ * @param {Event} ev
+ * @protected
+ */
+ // eslint-disable-next-line class-methods-use-this
+ _onClick(ev) {
+ // @ts-ignore
+ ev.target.value = ''; // eslint-disable-line no-param-reassign
+ }
+
+ /**
+ * @protected
+ */
+ _enhanceSelectedList() {
+ /**
+ * @type {LionSelectedFileList | null}
+ */
+ const selectedFileList = this.querySelector('[slot="selected-file-list"]');
+ if (selectedFileList) {
+ selectedFileList.setAttribute('id', `selected-file-list-${this._inputId}`);
+ this.addToAriaDescribedBy(selectedFileList, { idPrefix: 'selected-file-list' });
+ }
+ }
+
+ /**
+ * @protected
+ */
+ __syncAriaLabelledByAttributesToButton() {
+ if (this._inputNode.hasAttribute('aria-labelledby')) {
+ const ariaLabelledBy = this._inputNode.getAttribute('aria-labelledby');
+ this._buttonNode?.setAttribute(
+ 'aria-labelledby',
+ `select-button-${this._inputId} ${ariaLabelledBy}`,
+ );
+ }
+ }
+
+ /**
+ * @protected
+ */
+ __syncAriaDescribedByAttributesToButton() {
+ if (this._inputNode.hasAttribute('aria-describedby')) {
+ const ariaDescribedby = this._inputNode.getAttribute('aria-describedby') || '';
+ this._buttonNode?.setAttribute('aria-describedby', ariaDescribedby);
+ }
+ }
+
+ /**
+ * @private
+ */
+ __setupFileValidators() {
+ // TODO: update .param when _acceptCriteria changes
+ this.defaultValidators = [
+ new IsAcceptedFile(this._acceptCriteria),
+ this.__duplicateFileNamesValidator,
+ ];
+ }
+
+ /**
+ * Runs on drag or change event
+ *
+ * @param {InputFile[]} selectedFiles
+ * @protected
+ */
+ _processFiles(selectedFiles) {
+ // file size and type validators are required only when file is selected and not in case of prefill
+ // TODO: is this needed every time?
+ const newFiles = this.__computeNewAddedFiles(Array.from(selectedFiles));
+ if (!this.multiple && newFiles.length > 0) {
+ this._selectedFilesMetaData = [];
+ this._fileSelectResponse = [];
+ }
+
+ /**
+ * @type {InputFile}
+ */
+ let fileObj;
+ for (const selectedFile of newFiles.values()) {
+ // @ts-ignore
+ fileObj = new FileHandle(selectedFile, this._acceptCriteria);
+ if (fileObj.failedProp?.length) {
+ this._handleErroredFiles(fileObj);
+ this._fileSelectResponse = [
+ ...this._fileSelectResponse,
+ {
+ name: fileObj.systemFile.name,
+ status: 'FAIL',
+ // @ts-expect-error
+ errorMessage: fileObj.validationFeedback[0].message,
+ },
+ ];
+ } else {
+ this._fileSelectResponse = [
+ ...this._fileSelectResponse,
+ {
+ name: fileObj.systemFile.name,
+ status: 'SUCCESS',
+ },
+ ];
+ }
+ this._selectedFilesMetaData = [...this._selectedFilesMetaData, fileObj];
+ this._handleErrors();
+ }
+
+ // only send error-free files to file-list-changed event
+ const _successFiles = this._selectedFilesMetaData
+ .filter(
+ ({ systemFile, status }) =>
+ newFiles.includes(/** @type {InputFile} */ (systemFile)) && status === 'SUCCESS',
+ )
+ .map(({ systemFile }) => /** @type {InputFile} */ (systemFile));
+
+ if (_successFiles.length > 0) {
+ this._dispatchFileListChangeEvent(_successFiles);
+ }
+ }
+
+ /**
+ * @param {InputFile[]} newFiles
+ * @protected
+ */
+ _dispatchFileListChangeEvent(newFiles) {
+ this.dispatchEvent(
+ new CustomEvent('file-list-changed', {
+ // TODO: check if composed and bubbles are needed
+ // composed: true,
+ // bubbles: true,
+ detail: {
+ newFiles,
+ },
+ }),
+ );
+ }
+
+ /**
+ * @protected
+ */
+ _handleErrors() {
+ let hasErrors = false;
+ this._selectedFilesMetaData.forEach(fileObj => {
+ if (fileObj.failedProp && fileObj.failedProp.length > 0) {
+ hasErrors = true;
+ }
+ });
+
+ // TODO: handle via ValidateMixin (otherwise it breaks as soon as private ValidateMixin internals change)
+ if (hasErrors) {
+ this.hasFeedbackFor?.push('error');
+ // @ts-ignore use private property
+ this.shouldShowFeedbackFor.push('error');
+ } else if (this._prevHasErrors && this.hasFeedbackFor.includes('error')) {
+ const hasFeedbackForIndex = this.hasFeedbackFor.indexOf('error');
+ this.hasFeedbackFor.slice(hasFeedbackForIndex, hasFeedbackForIndex + 1);
+ // @ts-ignore use private property
+ const shouldShowFeedbackForIndex = this.shouldShowFeedbackFor.indexOf('error');
+ // @ts-ignore use private property
+ this.shouldShowFeedbackFor.slice(shouldShowFeedbackForIndex, shouldShowFeedbackForIndex + 1);
+ }
+ this._prevHasErrors = hasErrors;
+ }
+
+ /**
+ * @param {InputFile} fileObj
+ * @protected
+ */
+ /* eslint-disable no-param-reassign */
+ _handleErroredFiles(fileObj) {
+ fileObj.validationFeedback = [];
+ const { allowedFileExtensions, allowedFileTypes } = this._acceptCriteria;
+ /**
+ * @type {string[]}
+ */
+ let array = [];
+ let arrayLength = 0;
+ let lastItem;
+
+ if (allowedFileExtensions.length) {
+ array = allowedFileExtensions;
+ // eslint-disable-next-line no-return-assign
+ array = array.map(item => (item = `.${item}`));
+ lastItem = array.pop();
+ arrayLength = array.length;
+ } else if (allowedFileTypes.length) {
+ allowedFileTypes.forEach(MIMETypes => {
+ if (MIMETypes.endsWith('/*')) {
+ array.push(MIMETypes.slice(0, -2));
+ } else if (MIMETypes === 'text/plain') {
+ array.push('text');
+ } else {
+ const index = MIMETypes.indexOf('/');
+ const subTypes = MIMETypes.slice(index + 1);
+
+ if (!subTypes.includes('+')) {
+ array.push(`.${subTypes}`);
+ } else {
+ const subType = subTypes.split('+');
+ array.push(`.${subType[0]}`);
+ }
+ }
+ });
+ lastItem = array.pop();
+ arrayLength = array.length;
+ }
+ let message = '';
+ if (!lastItem) {
+ message = `${this.msgLit('lion-input-file:allowedFileSize', {
+ maxSize: formatBytes(this.maxFileSize),
+ })}`;
+ } else if (!arrayLength) {
+ message = `${this.msgLit('lion-input-file:allowedFileValidatorSimple', {
+ allowedType: lastItem,
+ maxSize: formatBytes(this.maxFileSize),
+ })}`;
+ } else {
+ message = `${this.msgLit('lion-input-file:allowedFileValidatorComplex', {
+ allowedTypesArray: array.join(', '),
+ allowedTypesLastItem: lastItem,
+ maxSize: formatBytes(this.maxFileSize),
+ })}`;
+ }
+
+ const errorObj = {
+ message,
+ type: 'error',
+ };
+ fileObj.validationFeedback?.push(errorObj);
+ }
+
+ /**
+ * @private
+ * @param {InputFile} removedFile
+ */
+ __removeFileFromList(removedFile) {
+ this._selectedFilesMetaData = this._selectedFilesMetaData.filter(
+ currentFile => currentFile.systemFile.name !== removedFile.systemFile.name,
+ );
+ // checks if the file is not a pre-filled file
+ if (this.modelValue) {
+ this.modelValue = this.modelValue.filter(
+ (/** @type {InputFile} */ currentFile) => currentFile.name !== removedFile.systemFile.name,
+ );
+ }
+ this._inputNode.value = '';
+ this._handleErrors();
+ }
+
+ /**
+ * @param {CustomEvent} ev
+ * @protected
+ */
+ _onRemoveFile(ev) {
+ if (this.disabled) {
+ return;
+ }
+ const { removedFile } = ev.detail;
+ if (!this.uploadOnSelect && removedFile) {
+ this.__removeFileFromList(removedFile);
+ }
+
+ this._removeFile(removedFile);
+ }
+
+ // TODO: this doesn't remove the file from the list, but fires an event
+ /**
+ * @param {InputFile} removedFile
+ * @protected
+ */
+ _removeFile(removedFile) {
+ this.dispatchEvent(
+ // TODO: check if composed and bubbles are needed
+ new CustomEvent('file-removed', {
+ // bubbles: true,
+ // composed: true,
+ detail: {
+ removedFile,
+ status: removedFile.status,
+ _fileSelectResponse: removedFile.response,
+ },
+ }),
+ );
+ }
+
+ /**
+ * Every time .formattedValue is attempted to sync to the view value (on change/blur and on
+ * modelValue change), this condition is checked. In case of the input-file we don't want
+ * this sync to happen, since the view value is already correct.
+ * @override FormatMixin
+ * @return {boolean}
+ * @protected
+ */
+ // eslint-disable-next-line class-methods-use-this
+ _reflectBackOn() {
+ return false;
+ }
+
+ /**
+ * Helper method for the mutually exclusive Required Validator
+ * @override ValidateMixin
+ */
+ _isEmpty() {
+ return this.modelValue?.length === 0;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @protected
+ */
+ _dropZoneTemplate() {
+ return html`
+