Feat/input file (#1881)

* feat(input-file): create input-file component

* chore: improvements after review

* chore: update after review

* chore: update translations

* chore: - fixed demo with form submit, submit was not prevented
       - fixed checking allowed file extensions
       - fixed clicking on select file button in drag and drop area

* chore: since the input-file does not upload files itself but enables user to select files, I replaced "upload" and "upload" with "select" and "selected" where applicable

* chore: - removed unused properties allowedFileTypes and allowedFileExtensions from lion-input-file
       - cleaned up docs

* chore: - changed type Array.<type> to Array<type>
       - removed redundant type definition

* fix: - FocusMixin: moved registering events for from connectedCallback to firstUpdated since _focusableNode is sometimes not available yet
     - SlotMixin: changed updated to update in since slots were rendered too late (related to previous fix in FocusMixin.js)

* fix: renamed lion-uploaded-file-list.js to lion-selected-file-list.js

* fix: fixed test for lion-selected-file-list

* fix: fixed typ

* wip

* fix: - fixed issue with multiple file selection where element would not select valid files after invalid ones
     - added getMessage method to FileValidation that returns empty string to prevent message being shown that error message must be configured
     - fixed tests

* chore: replaced `uploadOnFormSubmit` with `uploadOnSelect` and flipped the default value to false. When `uploadOnSelect` is set to true, the file will be uploaded as soon as it is selected.

* fix: - replaced `uploadOnFormSubmit` with `uploadOnSelect` and flipped the default value to false. When `uploadOnSelect` is set to true, the file will be uploaded as soon as it is selected.
     - fixed issue where a valid file was not selected and added to the file list if it was preceded by an invalid file

* chore: removed redundant README.md

* fix: fixed failing test

* chore: added missing type annotation

* chore: annotated event param as optional

---------

Co-authored-by: Danny Moerkerke <danny.moerkerke@ing.com>
Co-authored-by: Thijs Louisse <Thijs.Louisse@ing.com>
This commit is contained in:
gerjanvangeest 2023-06-06 11:30:43 +02:00 committed by GitHub
parent 649bcc6320
commit d2de984f0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 3807 additions and 73 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': minor
---
[input-file] Create input-file component

View file

@ -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.

View file

@ -0,0 +1,3 @@
# Input File ||20
-> go to Overview

View file

@ -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` <lion-input-file label="Upload" name="upload"></lion-input-file> `;
};
```
## 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';
```

View file

@ -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
<lion-input-file
multiple
label="Attachments"
help-text="Signature scan file"
max-file-size="1024000"
accept="application/PDF"
upload-on-form-submit
.uploadResponse="${uploadResponse}"
>
</lion-input-file>
```
### 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`
<lion-input-file
label="Label"
max-file-size="1024000"
accept=".jpg,.svg,.xml,image/svg+xml"
@file-list-changed="${ev => {
console.log('fileList', ev.detail.newFiles);
}}"
>
</lion-input-file>
`;
};
```
### 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`
<lion-input-file
accept=".jpg,.svg,.xml,image/svg+xml"
label="Upload"
enable-drop-zone
@file-list-changed="${ev => {
console.log(ev.detail.newFiles);
}}"
>
</lion-input-file>
`;
};
```
#### Maximum File Size
The `max-file-size` attribute sets the maximum file size in bytes.
```js preview-story
export const sizeValidator = () => {
return html`
<lion-input-file
max-file-size="2048"
label="Upload"
@file-list-changed="${ev => {
console.log(ev.detail.newFiles);
}}"
>
</lion-input-file>
`;
};
```
### 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`
<lion-input-file
label="Upload"
name="upload"
multiple
max-file-size="1024000"
@file-removed="${ev => {
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);
}}"
>
</lion-input-file>
`;
};
```
### 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`
<lion-input-file
label="Upload"
name="myFiles"
multiple
.validators="${[new Required()]}"
.uploadResponse="${[
{
name: 'file1.txt',
status: 'SUCCESS',
errorMessage: '',
id: '132',
},
{
name: 'file2.txt',
status: 'SUCCESS',
errorMessage: '',
id: 'abcd',
},
]}"
>
</lion-input-file>
`;
};
```
### 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`
<form @submit="${myFormSubmit}" @reset="${myFormReset}">
<lion-input-file
label="Upload"
name="upload"
multiple
.validators="${[new Required(), new FilenameLengthValidator({ maxFilenameLength: 20 })]}"
.uploadResponse="${[
{
name: 'file1.zip',
status: 'SUCCESS',
id: '132',
},
{
name: 'file2.zip',
status: 'SUCCESS',
id: 'abcd',
},
]}"
>
</lion-input-file>
<button type="reset">Reset</button>
<button>Upload</button>
</form>
`;
};
```
### 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`
<lion-input-file
label="Upload"
name="upload"
.multiple="${true}"
.uploadOnSelect="${true}"
@file-removed="${ev => {
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);
}}"
>
</lion-input-file>
`;
};
```
### 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`
<lion-input-file
label="Upload"
name="myFiles"
accept=".png"
max-file-size="1024000"
enable-drop-zone
.multiple="${true}"
.validators="${[new Required()]}"
>
</lion-input-file>
`;
};
```
### 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',
},
});
```

View file

@ -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 = () => {
<lion-input-amount name="money" label="Money"></lion-input-amount>
<lion-input-iban name="iban" label="Iban"></lion-input-iban>
<lion-input-email name="email" label="Email"></lion-input-email>
<lion-input-file name="file" label="File"></lion-input-file>
<lion-input-tel name="tel" label="Telephone number"></lion-input-tel>
<lion-checkbox-group
label="What do you like?"

View file

@ -90,8 +90,8 @@ const SlotMixinImplementation = superclass =>
* 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)) {

View file

@ -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();
}

View file

@ -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 }));
}

View file

@ -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);
}

View file

@ -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.

View file

@ -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;
};

View file

@ -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',

View file

@ -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,

View file

@ -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',

View file

@ -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 {
<lion-input-amount name="money" label="Money"></lion-input-amount>
<lion-input-iban name="iban" label="Iban"></lion-input-iban>
<lion-input-email name="email" label="Email"></lion-input-email>
<lion-input-file name="file" label="File"></lion-input-file>
<lion-input-tel name="tel" label="Telephone Number"></lion-input-tel>
<lion-input-tel-dropdown
name="tel-dropdown"
@ -151,7 +154,7 @@ export class UmbrellaForm extends LitElement {
.validators="${[new Required()]}"
>
<lion-checkbox
.choiceValue="agreed"
.choiceValue="${'agreed'}"
label="I blindly accept all terms and conditions"
></lion-checkbox>
</lion-checkbox-group>

View file

@ -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<string>; allowedFileExtensions: Array<string>; maxFileSize: number; }} _acceptCriteria
*/
constructor(systemFile, _acceptCriteria) {
/**
* @type {Array<string>}
*/
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);
}
}

View file

@ -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`<input .value="${ifDefined(this.getAttribute('value'))}" />`,
'file-select-button': () =>
html`<button
type="button"
id="select-button-${this._inputId}"
@click="${this.__openDialogOnBtnClick}"
>
${this.buttonLabel}
</button>`,
'selected-file-list': () => ({
template: html`
<lion-selected-file-list
.fileList=${this._selectedFilesMetaData}
.multiple=${this.multiple}
></lion-selected-file-list>
`,
}),
};
}
/**
* @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`
<div @drop="${this._processDroppedFiles}" class="input-file__drop-zone">
<div class="input-file__drop-zone__text">
${this.msgLit('lion-input-file:dragAndDropText')}
</div>
<slot name="file-select-button"></slot>
</div>
`;
}
/**
* @override FormControlMixin
* @return {TemplateResult}
* @protected
*/
// eslint-disable-next-line class-methods-use-this
_inputGroupAfterTemplate() {
return html` <slot name="selected-file-list"></slot> `;
}
/**
* @override FormControlMixin
* @return {TemplateResult}
* @protected
*/
_inputGroupInputTemplate() {
return html`
<slot name="input"> </slot>
${this.enableDropZone && this._isDragAndDropSupported
? this._dropZoneTemplate()
: html`
<div class="input-group__file-select-button">
<slot name="file-select-button"></slot>
</div>
`}
`;
}
static get styles() {
return [
super.styles,
css`
.input-group__container {
position: relative;
display: flex;
flex-direction: column;
width: fit-content;
}
:host([drop-zone]) .input-group__container {
width: auto;
}
.input-group__container ::slotted(input[type='file']) {
/** Invisible, since means of interaction is button */
position: absolute;
opacity: 0;
/** Full cover positioned, so it will be a drag and drop surface */
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.input-file__drop-zone {
display: flex;
position: relative;
flex-direction: column;
align-items: center;
border: dashed 2px black;
padding: 24px 0;
}
`,
];
}
/**
* @param {MouseEvent} ev
*/
__openDialogOnBtnClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this._inputNode.click();
}
}

View file

@ -0,0 +1,232 @@
import { uuid } from '@lion/ui/core.js';
import { LionValidationFeedback } from '@lion/ui/form-core.js';
import { LocalizeMixin } from '@lion/ui/localize.js';
import { ScopedElementsMixin } from '@open-wc/scoped-elements';
import { css, html, LitElement, nothing } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import { localizeNamespaceLoader } from './localizeNamespaceLoader.js';
/**
* @typedef {import('lit').TemplateResult} TemplateResult
* @typedef {import('../types/input-file.js').InputFile} InputFile
* @typedef {import('../types/input-file.js').SystemFile} SystemFile
*/
export class LionSelectedFileList extends LocalizeMixin(ScopedElementsMixin(LitElement)) {
static get scopedElements() {
return {
// @ts-expect-error [external] fix types scopedElements
...super.scopedElements,
'lion-validation-feedback': LionValidationFeedback,
};
}
static get properties() {
return {
fileList: { type: Array },
multiple: { type: Boolean },
};
}
static localizeNamespaces = [
{ 'lion-input-file': localizeNamespaceLoader },
...super.localizeNamespaces,
];
constructor() {
super();
/**
* @type {InputFile[]}
*/
this.fileList = [];
this.multiple = false;
}
/**
* @param {import('lit').PropertyValues} changedProperties
*/
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('fileList')) {
this._enhanceLightDomA11y();
}
}
_enhanceLightDomA11y() {
const fileFeedbackElementList = this.shadowRoot?.querySelectorAll('[id^="file-feedback"]');
// TODO: this will break as soon as a Subclasser changes the template ...
const inputFileNode = this.parentNode?.parentNode;
fileFeedbackElementList?.forEach(feedbackEl => {
// Generic focus/blur handling that works for both Fields/FormGroups
inputFileNode?.addEventListener('focusin', () => {
feedbackEl.setAttribute('aria-live', 'polite');
});
inputFileNode?.addEventListener('focusout', () => {
feedbackEl.setAttribute('aria-live', 'assertive');
});
});
}
/**
* @protected
* @param {InputFile} removedFile
*/
_removeFile(removedFile) {
this.dispatchEvent(
// TODO: do we need bubble and composed for an internal event that only LionInputFile should know about?
new CustomEvent('file-remove-requested', {
// composed: true,
// bubbles: true,
detail: {
removedFile,
status: removedFile.status,
_fileSelectResponse: removedFile.response,
},
}),
);
}
/**
* @protected
* @param {Array<import('../../form-core/types/validate/ValidateMixinTypes.js').FeedbackMessage>} validationFeedback
* @param {string} fileUuid
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_validationFeedbackTemplate(validationFeedback, fileUuid) {
return html`
<lion-validation-feedback
id="file-feedback-${fileUuid}"
.feedbackData="${validationFeedback}"
aria-live="assertive"
></lion-validation-feedback>
`;
}
/**
* @protected
* @param {InputFile} file
* @return {TemplateResult|nothing}
*/
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_listItemBeforeTemplate(file) {
return nothing;
}
/**
* @protected
* @param {InputFile} file
* @param {string} fileUuid
* @return {TemplateResult}
*/
// eslint-disable-next-line no-unused-vars
_listItemAfterTemplate(file, fileUuid) {
return html`
<button
class="selected__list__item__remove-button"
aria-label="${this.msgLit('lion-input-file:removeButtonLabel', {
fileName: file.systemFile.name,
})}"
@click=${() => this._removeFile(file)}
>
${this._removeButtonContentTemplate()}
</button>
`;
}
/**
* @protected
* @return {TemplateResult}
*/
// eslint-disable-next-line class-methods-use-this
_removeButtonContentTemplate() {
return html`✖️`;
}
/**
* @protected
* @param {InputFile} file
* @return {TemplateResult}
*/
_selectedListItemTemplate(file) {
const fileUuid = uuid();
return html`
<div class="selected__list__item" status="${file.status ? file.status.toLowerCase() : ''}">
<div class="selected__list__item__label">
${this._listItemBeforeTemplate(file)}
<span id="selected-list-item-label-${fileUuid}" class="selected__list__item__label__text">
<span class="sr-only">${this.msgLit('lion-input-file:fileNameDescriptionLabel')}</span>
${file.downloadUrl && file.status !== 'LOADING'
? html`
<a
class="selected__list__item__label__link"
href="${file.downloadUrl}"
target="${file.downloadUrl.startsWith('blob') ? '_blank' : ''}"
rel="${ifDefined(
file.downloadUrl.startsWith('blob') ? 'noopener noreferrer' : undefined,
)}"
>${file.systemFile?.name}</a
>
`
: file.systemFile?.name}
</span>
${this._listItemAfterTemplate(file, fileUuid)}
</div>
${file.status === 'FAIL' && file.validationFeedback
? html`
${repeat(
file.validationFeedback,
validationFeedback => html`
${this._validationFeedbackTemplate([validationFeedback], fileUuid)}
`,
)}
`
: nothing}
</div>
`;
}
render() {
return this.fileList?.length
? html`
${this.multiple
? html`
<ul class="selected__list">
${this.fileList.map(
file => html` <li>${this._selectedListItemTemplate(file)}</li> `,
)}
</ul>
`
: html` ${this._selectedListItemTemplate(this.fileList[0])} `}
`
: nothing;
}
static get styles() {
return [
css`
.selected__list {
list-style-type: none;
margin-block-start: 0;
margin-block-end: 0;
padding-inline-start: 0;
}
.sr-only {
position: absolute;
top: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(100%);
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
border: 0;
margin: 0;
padding: 0;
}
`,
];
}
}

View file

@ -0,0 +1,75 @@
/* eslint-disable import/no-extraneous-dependencies */
export const localizeNamespaceLoader = /** @param {string} locale */ locale => {
switch (locale) {
case 'bg-BG':
return import('@lion/ui/input-file-translations/bg-BG.js');
case 'bg':
return import('@lion/ui/input-file-translations/bg.js');
case 'cs-CZ':
return import('@lion/ui/input-file-translations/cs-CZ.js');
case 'cs':
return import('@lion/ui/input-file-translations/cs.js');
case 'de-DE':
return import('@lion/ui/input-file-translations/de-DE.js');
case 'de':
return import('@lion/ui/input-file-translations/de.js');
case 'en-AU':
return import('@lion/ui/input-file-translations/en-AU.js');
case 'en-GB':
return import('@lion/ui/input-file-translations/en-GB.js');
case 'en-US':
return import('@lion/ui/input-file-translations/en-US.js');
case 'en-PH':
case 'en':
return import('@lion/ui/input-file-translations/en.js');
case 'es-ES':
return import('@lion/ui/input-file-translations/es-ES.js');
case 'es':
return import('@lion/ui/input-file-translations/es.js');
case 'fr-FR':
return import('@lion/ui/input-file-translations/fr-FR.js');
case 'fr-BE':
return import('@lion/ui/input-file-translations/fr-BE.js');
case 'fr':
return import('@lion/ui/input-file-translations/fr.js');
case 'hu-HU':
return import('@lion/ui/input-file-translations/hu-HU.js');
case 'hu':
return import('@lion/ui/input-file-translations/hu.js');
case 'it-IT':
return import('@lion/ui/input-file-translations/it-IT.js');
case 'it':
return import('@lion/ui/input-file-translations/it.js');
case 'nl-BE':
return import('@lion/ui/input-file-translations/nl-BE.js');
case 'nl-NL':
return import('@lion/ui/input-file-translations/nl-NL.js');
case 'nl':
return import('@lion/ui/input-file-translations/nl.js');
case 'pl-PL':
return import('@lion/ui/input-file-translations/pl-PL.js');
case 'pl':
return import('@lion/ui/input-file-translations/pl.js');
case 'ro-RO':
return import('@lion/ui/input-file-translations/ro-RO.js');
case 'ro':
return import('@lion/ui/input-file-translations/ro.js');
case 'ru-RU':
return import('@lion/ui/input-file-translations/ru-RU.js');
case 'ru':
return import('@lion/ui/input-file-translations/ru.js');
case 'sk-SK':
return import('@lion/ui/input-file-translations/sk-SK.js');
case 'sk':
return import('@lion/ui/input-file-translations/sk.js');
case 'uk-UA':
return import('@lion/ui/input-file-translations/uk-UA.js');
case 'uk':
return import('@lion/ui/input-file-translations/uk.js');
case 'zh-CN':
case 'zh':
return import('@lion/ui/input-file-translations/zh.js');
default:
return import('@lion/ui/input-file-translations/en.js');
}
};

View file

@ -0,0 +1,114 @@
import { Validator } from '@lion/ui/form-core.js';
import { getLocalizeManager } from '@lion/ui/localize-no-side-effects.js';
/**
* @typedef {import('../../form-core/types/validate/validate.js').ValidatorConfig} ValidatorConfig
* @typedef {import('../../form-core/types/validate/validate.js').ValidatorParam} ValidatorParam
* @typedef {import('../../form-core/types/validate/validate.js').ValidatorOutcome} ValidatorOutcome
* @typedef {import('../types/input-file.js').InputFile} InputFile
*/
/* eslint max-classes-per-file: ["error", 2] */
export class IsAcceptedFile extends Validator {
static validatorName = 'IsAcceptedFile';
/**
* @param {number} fileFileSize
* @param {number} maxFileSize
*/
static checkFileSize(fileFileSize, maxFileSize) {
return fileFileSize <= maxFileSize;
}
/**
* Gets the extension of a file name
* @param {string} fileName like myFile.txt
* @return {string} like .txt
*/
static getExtension(fileName) {
return fileName?.slice(fileName.lastIndexOf('.'));
}
/**
* @param {string} extension
* @param {string[]} allowedFileExtensions
*/
static isExtensionAllowed(extension, allowedFileExtensions) {
return allowedFileExtensions?.find(
allowedExtension => allowedExtension.toUpperCase() === extension.toUpperCase(),
);
}
/**
* @param {string} type
* @param {string[]} allowedFileTypes
*/
static isFileTypeAllowed(type, allowedFileTypes) {
return allowedFileTypes?.find(allowedType => allowedType.toUpperCase() === type.toUpperCase());
}
/**
* @param {Array<File>} modelValue array of file objects
* @param {{ allowedFileTypes: string[]; allowedFileExtensions: string[]; maxFileSize: number; }} params
* @returns {Boolean}
*/
execute(modelValue, params = this.param) {
let isInvalidType;
let isInvalidFileExt;
const ctor = /** @type {typeof IsAcceptedFile} */ (this.constructor);
const { allowedFileTypes, allowedFileExtensions, maxFileSize } = params;
if (allowedFileTypes?.length) {
isInvalidType = modelValue.some(file => !ctor.isFileTypeAllowed(file.type, allowedFileTypes));
return isInvalidType;
}
if (allowedFileExtensions?.length) {
isInvalidFileExt = modelValue.some(
file => !ctor.isExtensionAllowed(ctor.getExtension(file.name), allowedFileExtensions),
);
return isInvalidFileExt;
}
const invalidFileSize = modelValue.findIndex(
file => !ctor.checkFileSize(file.size, maxFileSize),
);
return invalidFileSize > -1;
}
// @todo: validation should be handled with ValidateMixin so overriding getMessage to return an empty string should
// not be necessary anymore
static async getMessage() {
return '';
}
}
export class DuplicateFileNames extends Validator {
static validatorName = 'DuplicateFileNames';
/**
* @param {ValidatorParam} [param]
* @param {ValidatorConfig} [config]
*/
constructor(param, config) {
super(param, config);
this.type = 'info';
}
/**
* @param {InputFile[]} modelValue
* @param {ValidatorParam} [params]
* @returns {ValidatorOutcome|Promise<ValidatorOutcome>}
*/
execute(modelValue, params = this.param) {
return params.show;
}
/**
* @returns {Promise<string|Element>}
*/
static async getMessage() {
const localizeManager = getLocalizeManager();
// TODO: we need to make sure namespace is loaded
// TODO: keep Validators localize system agnostic
return localizeManager.msg('lion-input-file:uploadTextDuplicateFileName');
}
}

View file

@ -0,0 +1,170 @@
import { expect } from '@open-wc/testing';
import { IsAcceptedFile } from '../src/validators.js';
describe('lion-input-file: IsAcceptedFile', () => {
describe('valid file type', () => {
it('should return false for allowed file extension .csv', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ name: 'foo.csv' }], {
allowedFileExtensions: ['.csv'],
allowedFileTypes: undefined,
});
expect(returnVal).to.be.false;
});
it('should return false for allowed file extension .csv and .pdf', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ name: 'foo.pdf' }], {
allowedFileExtensions: ['.csv', '.pdf'],
allowedFileTypes: undefined,
});
expect(returnVal).to.be.false;
});
it('should return true for not allowed file extension .csv and .pdf', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ name: 'foo.pdf' }], {
allowedFileExtensions: ['.txt'],
allowedFileTypes: undefined,
});
expect(returnVal).to.be.true;
});
it('should return false for valid file type for single file', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ type: 'foo' }], {
allowedFileTypes: ['foo'],
});
expect(returnVal).to.be.false;
});
it('should return false for valid file type for multiple file', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'foo' }], {
allowedFileTypes: ['foo'],
});
expect(returnVal).to.be.false;
});
it('should return false if no file types are defined, and has small file size', async () => {
const fileTypeObj = new IsAcceptedFile();
const returnVal = fileTypeObj.execute(
[
// @ts-ignore ignore file typing
{ type: 'foo', size: 1 },
// @ts-ignore ignore file typing
{ type: 'foo', size: 1 },
],
{
allowedFileTypes: [],
maxFileSize: 1028,
},
);
expect(returnVal).to.be.false;
});
it('should return false for valid file type for multiple file and multiple allowed types', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'bar' }], {
allowedFileTypes: ['foo', 'bar'],
});
expect(returnVal).to.be.false;
});
});
describe('invalid file type', () => {
it('should return true for invalid file type for single file', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ type: 'bar' }], {
allowedFileTypes: ['foo'],
});
expect(returnVal).to.be.true;
});
it('should return false for invalid file type for multiple file', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'bar' }], {
allowedFileTypes: ['foo'],
});
expect(returnVal).to.be.true;
});
it('should return false for invalid file type for multiple file and multiple allowed types', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'pdf' }], {
allowedFileTypes: ['foo', 'bar'],
});
expect(returnVal).to.be.true;
});
it('should return true for invalid file type for empty type', async () => {
const fileTypeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileTypeObj.execute([{ type: '' }], {
allowedFileTypes: ['foo'],
});
expect(returnVal).to.be.true;
});
});
describe('valid file size', () => {
it('should return false for valid file size for single file', async () => {
const fileSizeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileSizeObj.execute([{ size: 5 }], {
maxFileSize: 10,
});
expect(returnVal).to.be.false;
});
it('should return false for valid file size for multiple file', async () => {
const fileSizeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileSizeObj.execute([{ size: 5 }, { size: 9 }], {
maxFileSize: 10,
});
expect(returnVal).to.be.false;
});
});
describe('invalid file size', () => {
it('should return true for invalid file size for single file', async () => {
const fileSizeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileSizeObj.execute([{ size: 5 }], {
maxFileSize: 3,
});
expect(returnVal).to.be.true;
});
it('should return false for invalid file size for multiple file', async () => {
const fileSizeObj = new IsAcceptedFile();
// @ts-ignore ignore file typing
const returnVal = fileSizeObj.execute([{ size: 5 }, { size: 9 }], {
maxFileSize: 7,
});
expect(returnVal).to.be.true;
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,207 @@
import '@lion/ui/define/lion-selected-file-list.js';
import { expect, fixture as _fixture, html, oneEvent } from '@open-wc/testing';
import sinon from 'sinon';
/**
* @typedef {import('../src/LionSelectedFileList.js').LionSelectedFileList} LionSelectedFileList
* @typedef {import('../types/input-file.js').InputFile} InputFile
* @typedef {import('lit').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult|string) => Promise<LionSelectedFileList>} */ (
_fixture
);
describe('lion-selected-file-list', () => {
/**
* @type {Partial<InputFile>}
*/
const fileSuccess = {
name: 'foo.txt',
status: 'SUCCESS',
downloadUrl: '#foo',
systemFile: {
name: 'foo.txt',
type: 'text/plain',
size: 1000,
status: 'SUCCESS',
},
response: {
name: 'foo',
type: 'text/plain',
size: 1000,
status: 'SUCCESS',
},
};
/**
* @type {Partial<InputFile>}
*/
const fileLoading = {
status: 'LOADING',
systemFile: {
name: 'bar.txt',
type: 'text/plain',
size: 100,
status: 'LOADING',
},
};
/**
* @type {Partial<InputFile>}
*/
const fileFail = {
systemFile: {
name: 'foobar.txt',
type: 'text/plain',
size: 1000,
status: 'FAIL',
},
status: 'FAIL',
validationFeedback: [{ message: 'foobar', type: 'error' }],
};
it('when empty show nothing', async () => {
const el = await fixture(html`<lion-selected-file-list></lion-selected-file-list>`);
expect(el.children.length).to.equal(0);
});
it('can have 1 item', async () => {
const el = await fixture(html`
<lion-selected-file-list .fileList="${[fileSuccess]}"></lion-selected-file-list>
`);
const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item');
expect(fileItems?.length).to.equal(1);
});
it('shows a list when multiple', async () => {
const el = await fixture(html`
<lion-selected-file-list
.fileList="${[fileSuccess, fileLoading]}"
.multiple="${true}"
></lion-selected-file-list>
`);
const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item');
expect(fileItems?.length).to.equal(2);
const fileList = el.shadowRoot?.querySelector('.selected__list');
expect(fileList?.tagName).to.equal('UL');
});
it('displays an anchor when status="SUCCESS" and a downloadUrl is available', async () => {
const el = await fixture(html`
<lion-selected-file-list
.fileList="${[fileSuccess, fileLoading, fileFail]}"
.multiple="${true}"
></lion-selected-file-list>
`);
const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item');
// @ts-ignore
expect(fileItems[0].querySelector('.selected__list__item__label__link')).to.exist;
// @ts-ignore
expect(fileItems[1].querySelector('.selected__list__item__label__link')).to.not.exist;
// @ts-ignore
expect(fileItems[2].querySelector('.selected__list__item__label__link')).to.not.exist;
});
it('displays a feedback message when status="FAIL"', async () => {
const el = await fixture(html`
<lion-selected-file-list .fileList="${[fileFail]}"></lion-selected-file-list>
`);
const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item');
const feedback = fileItems ? fileItems[0].querySelector('lion-validation-feedback') : undefined;
// @ts-ignore
expect(feedback).to.exist;
expect(feedback).shadowDom.to.equal('foobar');
});
it('should call removeFile method on click of remove button', async () => {
const el = await fixture(html`
<lion-selected-file-list .fileList="${[fileSuccess, fileLoading]}"></lion-selected-file-list>
`);
/**
* @type {HTMLButtonElement | null | undefined}
*/
const removeButton = el.shadowRoot?.querySelector('.selected__list__item__remove-button');
const removeFileSpy = sinon.spy(el, /** @type {keyof LionSelectedFileList} */ ('_removeFile'));
removeButton?.click();
expect(removeFileSpy).have.been.calledOnce;
removeFileSpy.restore();
});
it('should fire file-remove-requested event with removed file data', async () => {
const el = await fixture(html`
<lion-selected-file-list .fileList="${[fileSuccess, fileLoading]}"></lion-selected-file-list>
`);
/**
* @type {Partial<InputFile>}
*/
const removedFile = {
name: el.fileList[0].name,
status: el.fileList[0].status,
systemFile: {
name: el.fileList[0].name,
},
response: {
name: el.fileList[0].name,
status: el.fileList[0].status,
},
};
setTimeout(() => {
// @ts-ignore ignore file typing
el._removeFile(removedFile);
});
const removeFileEvent = await oneEvent(el, 'file-remove-requested');
expect(removeFileEvent).to.exist;
expect(removeFileEvent.detail.removedFile).to.deep.equal({
name: 'foo.txt',
status: 'SUCCESS',
systemFile: {
name: 'foo.txt',
},
response: {
name: 'foo.txt',
status: 'SUCCESS',
},
});
expect(removeFileEvent.detail.status).to.deep.equal('SUCCESS');
expect(removeFileEvent.detail._fileSelectResponse).to.deep.equal({
name: 'foo.txt',
status: 'SUCCESS',
});
});
describe('Accessibility', async () => {
it('is accessible', async () => {
const el = await fixture(
html`<lion-selected-file-list
.fileList="${[fileSuccess, fileLoading, fileFail]}"
></lion-selected-file-list>`,
);
await expect(el).to.be.accessible();
});
it(`adds aria-live="polite" to the feedback slot on focus, aria-live="assertive" to the feedback slot on blur,
so error messages appearing on blur will be read before those of the next input`, async () => {
const el = await fixture(html`
<lion-selected-file-list .fileList="${[fileFail]}"></lion-selected-file-list>
`);
const fileFeedback = el?.shadowRoot?.querySelectorAll('[id^="file-feedback"]')[0];
const removeButton = /** @type {HTMLButtonElement} */ (
el.shadowRoot?.querySelector('.selected__list__item__remove-button')
);
// @ts-ignore
expect(fileFeedback.getAttribute('aria-live')).to.equal('assertive');
removeButton?.focus();
// @ts-ignore
expect(fileFeedback.getAttribute('aria-live')).to.equal('polite');
removeButton?.blur();
// @ts-ignore
expect(fileFeedback.getAttribute('aria-live')).to.equal('assertive');
});
});
});

View file

@ -0,0 +1,5 @@
import bg from './bg.js';
export default {
...bg,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Моля, качете файл с макс. размер {maxSize}.',
allowedFileValidatorSimple: 'Моля, качете файл от тип {allowedType} с макс. размер {maxSize}.',
allowedFileValidatorComplex:
'Моля, качете файл от тип {allowedTypesArray} или {allowedTypesLastItem} с макс. размер {maxSize}.',
dragAndDropText: 'Плъзнете и пуснете Вашите файлове тук или',
fileNameDescriptionLabel: 'Име на файл: {fileName}',
removeButtonLabel: 'Отстраняване на файла {fileName}',
selectTextDuplicateFileName: 'Файл със същото име на файл вече е налице.',
selectTextMultipleFile: 'Избор на файлове',
selectTextSingleFile: 'Избор на файл',
};

View file

@ -0,0 +1,5 @@
import cs from './cs.js';
export default {
...cs,
};

View file

@ -0,0 +1,13 @@
export default {
allowedFileSize: 'Nahrajte soubor s max. velikostí {maxSize}.',
allowedFileValidatorSimple:
'Nahrajte soubor typu {allowedTypesLastItem} s max. velikostí {maxSize}.',
allowedFileValidatorComplex:
'Nahrajte soubor typu {allowedTypesArray} nebo {allowedTypesLastItem} s max. velikostí {maxSize}.',
dragAndDropText: 'Přetáhněte soubory sem nebo',
fileNameDescriptionLabel: 'Název souboru: {fileName}',
removeButtonLabel: 'Odebrat soubor {fileName}',
selectTextDuplicateFileName: 'Soubor se stejným názvem byl již přítomen.',
selectTextMultipleFile: 'Vybrat soubory',
selectTextSingleFile: 'Vybrat soubor',
};

View file

@ -0,0 +1,5 @@
import de from './de.js';
export default {
...de,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Laden Sie eine Datei mit max. {maxSize} hoch.',
allowedFileValidatorSimple: 'Laden Sie eine {allowedType}-Datei mit max. {maxSize} hoch.',
allowedFileValidatorComplex:
'Laden Sie eine {allowedTypesArray} oder {allowedTypesLastItem}-Datei mit max. {maxSize} hoch.',
dragAndDropText: 'Ziehen Sie Ihre Dateien per Drag & Drop hierher oder',
fileNameDescriptionLabel: 'Dateiname: {fileName}',
removeButtonLabel: 'Datei {fileName} entfernen',
selectTextDuplicateFileName: 'Eine Datei mit demselben Dateinamen war bereits vorhanden.',
selectTextMultipleFile: 'Dateien auswählen',
selectTextSingleFile: 'Datei auswählen',
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,5 @@
import en from './en.js';
export default {
...en,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Please select a file with max {maxSize}.',
allowedFileValidatorSimple: 'Please select a(n) {allowedType} file with max {maxSize}.',
allowedFileValidatorComplex:
'Please select a {allowedTypesArray} or {allowedTypesLastItem} file with max {maxSize}.',
dragAndDropText: 'Drag & Drop your files here or', // TODO: or what? Why is Drag & Drop capitalized?
fileNameDescriptionLabel: 'File name: {fileName}',
removeButtonLabel: 'Remove {fileName} file',
selectTextDuplicateFileName: 'A file with same filename was already present.',
selectTextMultipleFile: 'Select files',
selectTextSingleFile: 'Select file',
};

View file

@ -0,0 +1,5 @@
import es from './es.js';
export default {
...es,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Cargue un archivo de {maxSize} como máximo.',
allowedFileValidatorSimple: 'Cargue un archivo {allowedType} de {maxSize} como máximo.',
allowedFileValidatorComplex:
'Cargue un archivo {allowedTypesArray} o {allowedTypesLastItem} de {maxSize} como máximo.',
dragAndDropText: 'Arrastre y suelte los archivos aquí o',
fileNameDescriptionLabel: 'Nombre de archivo: {fileName}',
removeButtonLabel: 'Elimine el archivo: {fileName}',
selectTextDuplicateFileName: 'Ya había un archivo con el mismo nombre de archivo.',
selectTextMultipleFile: 'Seleccionar archivos',
selectTextSingleFile: 'Seleccionar archivo',
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,5 @@
import fr from './fr.js';
export default {
...fr,
};

View file

@ -0,0 +1,13 @@
export default {
allowedFileSize: 'Veuillez télécharger un fichier avec une taille maximale de {maxSize}.',
allowedFileValidatorSimple:
'Veuillez télécharger un fichier {allowedType} avec une taille maximale de {maxSize}.',
allowedFileValidatorComplex:
'Veuillez télécharger un fichier {allowedTypesArray} ou {allowedTypesLastItem} avec une taille maximale de {maxSize}.',
dragAndDropText: 'Glissez et déposez vos fichiers ici ou',
fileNameDescriptionLabel: 'Nom de fichier: {fileName}',
removeButtonLabel: 'Supprimer le fichier {fileName}',
selectTextDuplicateFileName: 'Un fichier portant le même nom de fichier était déjà présent.',
selectTextMultipleFile: 'Sélectionnez des fichiers',
selectTextSingleFile: 'Sélectionnez un fichier',
};

View file

@ -0,0 +1,5 @@
import hu from './hu.js';
export default {
...hu,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Töltsön fel egy legfeljebb {maxSize} méretű fájlt.',
allowedFileValidatorSimple: 'Töltsön fel egy legfeljebb {maxSize} méretű {allowedType} fájlt.',
allowedFileValidatorComplex:
'Töltsön fel egy legfeljebb {maxSize} méretű {allowedTypesArray} vagy {allowedTypesLastItem} fájlt.',
dragAndDropText: 'Húzza át a fájlokat ide vagy',
fileNameDescriptionLabel: 'Fájlnév: {fileName}',
removeButtonLabel: 'A(z) {fileName} fájl eltávolítása',
selectTextDuplicateFileName: 'Már volt ilyen nevű fájl.',
selectTextMultipleFile: 'Fájl(ok) kiválasztása',
selectTextSingleFile: 'Fájl kiválasztása',
};

View file

@ -0,0 +1,5 @@
import it from './it.js';
export default {
...it,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Caricare un file di {maxSize} max.',
allowedFileValidatorSimple: 'Caricare un file {allowedType} di {maxSize} max.',
allowedFileValidatorComplex:
'Caricare un file {allowedTypesArray} o {allowedTypesLastItem} di {maxSize} max.',
dragAndDropText: 'Trascinare i file qui o',
fileNameDescriptionLabel: 'Nome file: {fileName}',
removeButtonLabel: 'Rimuovere il file {fileName}',
selectTextDuplicateFileName: 'Un file con lo stesso nome file era già presente.',
selectTextMultipleFile: 'Seleziona file',
selectTextSingleFile: 'Seleziona file',
};

View file

@ -0,0 +1,5 @@
import nl from './nl.js';
export default {
...nl,
};

View file

@ -0,0 +1,5 @@
import nl from './nl.js';
export default {
...nl,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Upload een bestand van maximaal {maxSize}.',
allowedFileValidatorSimple: 'Upload een {allowedType}-bestand van maximaal {maxSize}.',
allowedFileValidatorComplex:
'Upload een {allowedTypesArray} of {allowedTypesLastItem}-bestand van maximaal {maxSize}.',
dragAndDropText: 'Sleep uw bestanden hierheen of',
fileNameDescriptionLabel: 'Bestandsnaam: {fileName}',
removeButtonLabel: 'Verwijder het bestand {fileName}',
selectTextDuplicateFileName: 'Er bestaat al een bestand met dezelfde bestandsnaam.',
selectTextMultipleFile: 'Selecteer bestanden',
selectTextSingleFile: 'Selecteer bestand',
};

View file

@ -0,0 +1,5 @@
import pl from './pl.js';
export default {
...pl,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Prześlij plik o maks. rozmiarze {maxSize}.',
allowedFileValidatorSimple: 'Prześlij plik {allowedType} o maks. rozmiarze {maxSize}.',
allowedFileValidatorComplex:
'Prześlij plik {allowedTypesArray} lub {allowedTypesLastItem} o maks. rozmiarze {maxSize}.',
dragAndDropText: 'Przeciągnij i upuść pliki tutaj lub',
fileNameDescriptionLabel: 'Nazwa pliku: {fileName}',
removeButtonLabel: 'Usuń plik {fileName}',
selectTextDuplicateFileName: 'Plik o tej samej nazwie już istnieje.',
selectTextMultipleFile: 'Wybierz pliki',
selectTextSingleFile: 'Wybierz plik',
};

View file

@ -0,0 +1,5 @@
import ro from './ro.js';
export default {
...ro,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Încărcaţi un fişier de max. {maxSize}.',
allowedFileValidatorSimple: 'Încărcaţi un fişier {allowedType} de max. {maxSize}.',
allowedFileValidatorComplex:
'Încărcaţi un fişier {allowedTypesArray} sau {allowedTypesLastItem} de max. {maxSize}.',
dragAndDropText: 'Glisaţi şi fixaţi fişierele aici sau',
fileNameDescriptionLabel: 'Nume fişier: {fileName}',
removeButtonLabel: 'Eliminaţi fişierul {filename}',
selectTextDuplicateFileName: 'Există deja un fişier cu acelaşi nume de fişier.',
selectTextMultipleFile: 'Selectare fișiere',
selectTextSingleFile: 'Selectare fișier',
};

View file

@ -0,0 +1,5 @@
import ru from './ru.js';
export default {
...ru,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Загрузите файл размером не более {maxSize}.',
allowedFileValidatorSimple: 'Загрузите файл {allowedType} размером не более {maxSize}.',
allowedFileValidatorComplex:
'Загрузите файл {allowedTypesArray} или {allowedTypesLastItem} размером не более {maxSize}.',
dragAndDropText: 'Перетащите файлы сюда или',
fileNameDescriptionLabel: 'Название файла: {fileName}',
removeButtonLabel: 'Удалить файл {fileName}',
selectTextDuplicateFileName: 'Файл с таким названием уже существует.',
selectTextMultipleFile: 'Выберите файлы',
selectTextSingleFile: 'Выберите файл',
};

View file

@ -0,0 +1,5 @@
import sk from './sk.js';
export default {
...sk,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Nahrajte súbor s maximálnou veľkosťou {maxSize}.',
allowedFileValidatorSimple: 'Nahrajte súbor {allowedType} s maximálnou veľkosťou {maxSize}.',
allowedFileValidatorComplex:
'Nahrajte súbor {allowedTypesArray} alebo {allowedTypesLastItem} s maximálnou veľkosťou {maxSize}.',
dragAndDropText: 'Súbory presuňte sem alebo',
fileNameDescriptionLabel: 'Názov súboru: {fileName}',
removeButtonLabel: 'Odstrániť súbor {fileName}',
selectTextDuplicateFileName: 'Súbor s rovnakým názvom súboru už existoval.',
selectTextMultipleFile: 'Vybrať súbory',
selectTextSingleFile: 'Vybrať súbor',
};

View file

@ -0,0 +1,5 @@
import uk from './uk.js';
export default {
...uk,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: 'Завантажте файл розміром до {maxSize}.',
allowedFileValidatorSimple: 'Завантажте файл {allowedType} розміром до {maxSize}.',
allowedFileValidatorComplex:
'Завантажте файл {allowedTypesArray} або {allowedTypesLastItem} розміром до {maxSize}.',
dragAndDropText: 'Перетягніть файли сюди або',
fileNameDescriptionLabel: 'Ім’я файлу: {fileName}',
removeButtonLabel: 'Видалення файлу {fileName}',
selectTextDuplicateFileName: 'Файл із такою назвою вже існував.',
selectTextMultipleFile: 'Виберіть файли',
selectTextSingleFile: 'Виберіть файл',
};

View file

@ -0,0 +1,5 @@
import zh from './zh.js';
export default {
...zh,
};

View file

@ -0,0 +1,12 @@
export default {
allowedFileSize: '请上传最大 {maxSize} 的文件。',
allowedFileValidatorSimple: '请上传最大 {maxSize} 的 {allowedType} 文件。',
allowedFileValidatorComplex:
'请上传最大 {maxSize} 的 {allowedTypesArray} 或 {allowedTypesLastItem} 文件。',
dragAndDropText: '将您的文件拖放到此处,或',
fileNameDescriptionLabel: '文件名: {fileName}',
removeButtonLabel: '删除 {fileName} 文件',
selectTextDuplicateFileName: '已存在具有相同文件名的文件。',
selectTextMultipleFile: '选择多个文件',
selectTextSingleFile: '选择文件',
};

View file

@ -0,0 +1,32 @@
import { FeedbackMessage } from "../../form-core/types/validate/ValidateMixinTypes.js";
type FileBasics = {
name: string;
/** size in bytes */
size: number;
type: string;
status?: 'SUCCESS' | 'FAIL' | 'LOADING';
};
export type InputFile = {
downloadUrl?: string;
errorMessage?: FeedbackMessage.message;
failedProp?: Array<string|number>;
response?: FileSelectResponse;
systemFile: Partial<SystemFile>;
validationFeedback?: Array<FeedbackMessage>;
} & FileBasics & Partial<File>;
export type SystemFile = {
downloadUrl?: string;
errorMessage?: FeedbackMessage.message;
failedProp?: Array<string|number>;
response?: FileSelectResponse;
} & FileBasics & Partial<File>;
export type FileSelectResponse = {
downloadUrl?: string;
errorMessage?: FeedbackMessage.message;
id?: string;
} & Partial<FileBasics>;

View file

@ -0,0 +1,3 @@
import { LionInputFile } from '../input-file.js';
customElements.define('lion-input-file', LionInputFile);

View file

@ -0,0 +1,3 @@
import { LionSelectedFileList } from '../input-file.js';
customElements.define('lion-selected-file-list', LionSelectedFileList);

View file

@ -0,0 +1,3 @@
export { LionInputFile } from '../components/input-file/src/LionInputFile.js';
export { LionSelectedFileList } from '../components/input-file/src/LionSelectedFileList.js';
export { IsAcceptedFile, DuplicateFileNames } from '../components/input-file/src/validators.js';

View file

@ -21,6 +21,7 @@
"./pagination-translations/*": "./components/pagination/translations/*",
"./progress-indicator-translations/*": "./components/progress-indicator/translations/*",
"./input-datepicker-translations/*": "./components/input-datepicker/translations/*",
"./input-file-translations/*": "./components/input-file/translations/*",
"./input-iban-translations/*": "./components/input-iban/translations/*",
"./input-tel-translations/*": "./components/input-tel/translations/*",
"./overlays-translations/*": "./components/overlays/translations/*",