Merge branch 'file' into 'main'
File See merge request cellule-financiere-pmo/design-system/visua-vue!10
This commit is contained in:
commit
2c91cfb38c
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.10] - 2025-07-27
|
||||
### Added
|
||||
- File compoenent
|
||||
- File upload component
|
||||
- Scroll panel component
|
||||
|
||||
## [1.0.9] - 2025-07-24
|
||||
### Added
|
||||
- Message component
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
# visua-vue
|
||||
|
||||
**Current version: v1.0.9**
|
||||
**Current version: v1.0.10**
|
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@cellule-financiere-pmo/visua-vue",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cellule-financiere-pmo/visua-vue",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@cellule-financiere-pmo/visua": "1.1.3",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@cellule-financiere-pmo/visua-vue",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
14
src/App.vue
14
src/App.vue
|
@ -5,10 +5,11 @@
|
|||
// import VAccordionView from '../template/VAccordionView.vue';
|
||||
// import VInputView from '../template/VInputView.vue';
|
||||
// import VCheckboxView from '../template/VCheckboxView.vue';
|
||||
import VBadgeView from '../template/VBadgeView.vue';
|
||||
import VSelectView from '../template/VSelectView.vue';
|
||||
// import VBadgeView from '../template/VBadgeView.vue';
|
||||
// import VSelectView from '../template/VSelectView.vue';
|
||||
// import VProgressBarView from '../template/VProgressBarView.vue';
|
||||
import VMessageView from '../template/VMessageView.vue';
|
||||
// import VMessageView from '../template/VMessageView.vue';
|
||||
import VFileUploadView from '../template/VFileUploadView.vue'
|
||||
</script>
|
||||
|
||||
|
||||
|
@ -19,8 +20,9 @@ import VMessageView from '../template/VMessageView.vue';
|
|||
<!-- <VAccordionView/> -->
|
||||
<!-- <VInputView/> -->
|
||||
<!-- <VCheckboxView/> -->
|
||||
<VBadgeView/>
|
||||
<VSelectView/>
|
||||
<!-- <VBadgeView/>
|
||||
<VSelectView/> -->
|
||||
<!-- <VProgressBarView/> -->
|
||||
<VMessageView/>
|
||||
<!-- <VMessageView/> -->
|
||||
<VFileUploadView/>
|
||||
</template>
|
||||
|
|
|
@ -13,3 +13,5 @@
|
|||
@import './primevue-style/overlay.css';
|
||||
@import './primevue-style/iconfield.css';
|
||||
@import './primevue-style/progressbar.css';
|
||||
@import './primevue-style/fileupload.css';
|
||||
@import './primevue-style/scrollpanel.css';
|
||||
|
|
24
src/assets/style/primevue-style/fileupload.css
Normal file
24
src/assets/style/primevue-style/fileupload.css
Normal file
|
@ -0,0 +1,24 @@
|
|||
:root {
|
||||
--p-fileupload-basic-gap: 0.5rem;
|
||||
--p-fileupload-progressbar-height: 0.25rem;
|
||||
--p-fileupload-file-list-gap: 0.5rem;
|
||||
--p-fileupload-file-padding: 1rem;
|
||||
--p-fileupload-file-gap: 1rem;
|
||||
--p-fileupload-file-border-color: var(--border-default-grey);
|
||||
--p-fileupload-file-info-gap: 0.5rem;
|
||||
--p-fileupload-content-highlight-border-color: var(--p-primary-color);
|
||||
--p-fileupload-content-padding: 0rem;
|
||||
--p-fileupload-content-gap: 1rem;
|
||||
--p-fileupload-header-background: transparent;
|
||||
--p-fileupload-header-color: var(--text-default-grey);
|
||||
--p-fileupload-header-padding: 0rem;
|
||||
--p-fileupload-header-border-color: unset;
|
||||
--p-fileupload-header-border-width: 0;
|
||||
--p-fileupload-header-border-radius: 0;
|
||||
--p-fileupload-header-gap: 0.5rem;
|
||||
--p-fileupload-background: var(--background-transparent);
|
||||
--p-fileupload-border-color: var(--border-default-grey);
|
||||
--p-fileupload-color: var(--text-default-grey);
|
||||
--p-fileupload-border-radius: 0px;
|
||||
--p-fileupload-transition-duration: var(--transition-duration);
|
||||
}
|
11
src/assets/style/primevue-style/scrollpanel.css
Normal file
11
src/assets/style/primevue-style/scrollpanel.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
:root {
|
||||
--p-scrollpanel-bar-size: 0.5rem;
|
||||
--p-scrollpanel-bar-border-radius: 0.25rem;
|
||||
--p-scrollpanel-bar-focus-ring-width: var(--focus-width);
|
||||
--p-scrollpanel-bar-focus-ring-style: var(--focus-style);
|
||||
--p-scrollpanel-bar-focus-ring-color: var(--focus-color);
|
||||
--p-scrollpanel-bar-focus-ring-offset: var(--focus-offset);
|
||||
--p-scrollpanel-bar-focus-ring-shadow: none;
|
||||
--p-scrollpanel-transition-duration: var(--transition-duration);
|
||||
--p-scrollpanel-bar-background: var(--background-overlay-grey);
|
||||
}
|
198
src/components/file/IVFileUpload.type.ts
Normal file
198
src/components/file/IVFileUpload.type.ts
Normal file
|
@ -0,0 +1,198 @@
|
|||
import type { HintedString } from '@primevue/core';
|
||||
|
||||
/**
|
||||
* Interface representing the properties of a FileUpload component.
|
||||
*/
|
||||
export interface IFile {
|
||||
/**
|
||||
* Optional unique identifier for the file input.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* Optional label displayed above or beside the file input.
|
||||
*/
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* Specifies the types of files that the input accepts.
|
||||
* Can be a single MIME type string or an array of MIME types.
|
||||
* Example: "image/*" or ["image/png", "application/pdf"]
|
||||
*/
|
||||
accept?: string | string[];
|
||||
|
||||
/**
|
||||
* Optional helper text displayed below the input to guide the user.
|
||||
*/
|
||||
hint?: string;
|
||||
|
||||
/**
|
||||
* Optional error message displayed when validation fails.
|
||||
*/
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* Optional message displayed when the input is valid.
|
||||
*/
|
||||
validMessage?: string;
|
||||
|
||||
/**
|
||||
* If true, disables the file input.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The current value of the file input, typically a file path or name.
|
||||
*/
|
||||
modelValue?: string;
|
||||
}
|
||||
|
||||
export interface FileUploadProps {
|
||||
/**
|
||||
* Name of the request parameter to identify the files at backend.
|
||||
*/
|
||||
name?: string | undefined;
|
||||
/**
|
||||
* Remote url to upload the files.
|
||||
*/
|
||||
url?: string | undefined;
|
||||
/**
|
||||
* Defines the UI of the component, possible values are 'advanced' and 'basic'.
|
||||
* @defaultValue advanced
|
||||
*/
|
||||
mode?: HintedString<'advanced' | 'basic'> | undefined;
|
||||
/**
|
||||
* Used to select multiple files at once from file dialog.
|
||||
* @defaultValue false
|
||||
*/
|
||||
multiple?: boolean | undefined;
|
||||
/**
|
||||
* Pattern to restrict the allowed file types such as 'image/*'.
|
||||
*/
|
||||
accept?: string | undefined;
|
||||
/**
|
||||
* Disables the upload functionality.
|
||||
* @defaultValue false
|
||||
*/
|
||||
disabled?: boolean | undefined;
|
||||
/**
|
||||
* When enabled, upload begins automatically after selection is completed.
|
||||
* @defaultValue false
|
||||
*/
|
||||
auto?: boolean | undefined;
|
||||
/**
|
||||
* Maximum file size allowed in bytes.
|
||||
*/
|
||||
maxFileSize?: number | undefined;
|
||||
/**
|
||||
* Message of the invalid fize size.
|
||||
* @defaultValue {0}: Invalid file size, file size should be smaller than {1.}
|
||||
*/
|
||||
invalidFileSizeMessage?: string | undefined;
|
||||
/**
|
||||
* Message to display when number of files to be uploaded exceeeds the limit.
|
||||
* @defaultValue Maximum number of files to be uploaded is {0.}
|
||||
*/
|
||||
invalidFileLimitMessage?: string | undefined;
|
||||
/**
|
||||
* Message of the invalid fize type.
|
||||
* @defaultValue '{0}: Invalid file type.'
|
||||
*/
|
||||
invalidFileTypeMessage?: string | undefined;
|
||||
/**
|
||||
* Maximum number of files that can be uploaded.
|
||||
*/
|
||||
fileLimit?: number | undefined;
|
||||
/**
|
||||
* Cross-site Access-Control requests should be made using credentials such as cookies, authorization headers or TLS client certificates.
|
||||
* @defaultValue false
|
||||
*/
|
||||
withCredentials?: boolean | undefined;
|
||||
/**
|
||||
* Width of the image thumbnail in pixels.
|
||||
* @defaultValue 50
|
||||
*/
|
||||
previewWidth?: number | undefined;
|
||||
/**
|
||||
* Label of the choose button. Defaults to PrimeVue Locale configuration.
|
||||
*/
|
||||
chooseLabel?: string | undefined;
|
||||
/**
|
||||
* Label of the upload button. Defaults to PrimeVue Locale configuration.
|
||||
*/
|
||||
uploadLabel?: string | undefined;
|
||||
/**
|
||||
* Label of the cancel button. Defaults to PrimeVue Locale configuration.
|
||||
* @defaultValue Cancel
|
||||
*/
|
||||
cancelLabel?: string | undefined;
|
||||
/**
|
||||
* Whether to use the default upload or a manual implementation defined in uploadHandler callback. Defaults to PrimeVue Locale configuration.
|
||||
*/
|
||||
customUpload?: boolean | undefined;
|
||||
/**
|
||||
* Whether to show the upload button.
|
||||
* @defaultValue true
|
||||
*/
|
||||
showUploadButton?: boolean | undefined;
|
||||
/**
|
||||
* Whether to show the cancel button.
|
||||
* @defaultValue true
|
||||
*/
|
||||
showCancelButton?: boolean | undefined;
|
||||
/**
|
||||
* Icon of the choose button.
|
||||
*/
|
||||
chooseIcon?: string | undefined;
|
||||
/**
|
||||
* Icon of the upload button.
|
||||
*/
|
||||
uploadIcon?: string | undefined;
|
||||
/**
|
||||
* Icon of the cancel button.
|
||||
*/
|
||||
cancelIcon?: string | undefined;
|
||||
/**
|
||||
* Inline style of the component.
|
||||
*/
|
||||
style?: unknown;
|
||||
/**
|
||||
* Style class of the component.
|
||||
*/
|
||||
class?: unknown;
|
||||
/**
|
||||
* Used to pass all properties of the ButtonProps to the choose button inside the component.
|
||||
* @type {ButtonProps}
|
||||
* @defaultValue null
|
||||
*/
|
||||
chooseButtonProps?: object | undefined;
|
||||
/**
|
||||
* Used to pass all properties of the ButtonProps to the upload button inside the component.
|
||||
* @type {ButtonProps}
|
||||
* @defaultValue { severity: 'secondary' }
|
||||
*/
|
||||
uploadButtonProps?: object | undefined;
|
||||
/**
|
||||
* Used to pass all properties of the ButtonProps to the cancel button inside the component.
|
||||
* @type {ButtonProps}
|
||||
* @defaultValue { severity: 'secondary' }
|
||||
*/
|
||||
cancelButtonProps?: object | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended interface for a customizable FileUpload component.
|
||||
*
|
||||
* Combines selected properties from IFile and FileUploadProps,
|
||||
* while omitting and overriding specific ones for more control.
|
||||
*/
|
||||
export default interface IVFileUpload extends
|
||||
Partial<Omit<IFile, 'accept' | 'error'>>,
|
||||
Partial<Omit<FileUploadProps, 'auto' | 'mode' | 'multiple'>> {
|
||||
|
||||
/**
|
||||
* If true, enables the advanced mode of the file upload component,
|
||||
* which may include features like drag-and-drop, file previews, etc.
|
||||
*/
|
||||
advanced?: boolean;
|
||||
}
|
229
src/components/file/VFile.vue
Normal file
229
src/components/file/VFile.vue
Normal file
|
@ -0,0 +1,229 @@
|
|||
<script setup lang="ts">
|
||||
import VBadge from '../badge/VBadge.vue';
|
||||
import VButton from '../button/VButton.vue';
|
||||
import VProgressBar from '../progressbar/VProgressBar.vue';
|
||||
import { usePrimeVue } from 'primevue';
|
||||
import { ref, computed } from 'vue';
|
||||
import styles from '@visua/typography.module.css';
|
||||
|
||||
export interface IVFile {
|
||||
file: File
|
||||
advanced?: boolean
|
||||
index?: number
|
||||
removeFileCallback?: (index: number) => void
|
||||
status?: 'pending' | 'completed' | 'error'
|
||||
progress?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IVFile>(), {
|
||||
advanced: false,
|
||||
index: 0,
|
||||
removeFileCallback: () => {},
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
const badgeType = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'pending': return 'warning'
|
||||
case 'completed': return 'success'
|
||||
case 'error': return 'error'
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
|
||||
const badgeLabel = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'pending': return 'En attente'
|
||||
case 'completed': return 'Terminé'
|
||||
case 'error': return 'Annulé'
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})
|
||||
|
||||
const $primevue = usePrimeVue();
|
||||
const totalSize = ref(0);
|
||||
const totalSizePercent = ref(0);
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
const k = 1024;
|
||||
const dm = 3;
|
||||
const sizes = $primevue.config.locale?.fileSizeTypes ?? ['B', 'KB', 'MB', 'GB', 'TB'];;
|
||||
|
||||
if (bytes === 0) {
|
||||
return `0 ${ sizes[0]}`;
|
||||
}
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const formattedSize = parseFloat((bytes / k ** i).toFixed(dm));
|
||||
|
||||
return `${formattedSize} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const onRemoveTemplatingFile = (file: File, removeFileCallback: (index: number) => void, index: number) => {
|
||||
removeFileCallback(index);
|
||||
totalSize.value -= parseInt(formatSize(file.size));
|
||||
totalSizePercent.value = totalSize.value / 10;
|
||||
};
|
||||
|
||||
const getFileIconClass = (file: File) => {
|
||||
const type = file?.name.toLowerCase();
|
||||
if (type?.endsWith('.pdf')) return 'ri-file-pdf-2-line';
|
||||
if (type?.endsWith('.doc') || type?.endsWith('.docx')) return 'ri-file-word-2-line';
|
||||
if (type?.endsWith('.xls') || type?.endsWith('.xlsx')) return 'ri-file-excel-2-line';
|
||||
if (type?.endsWith('.txt')) return 'ri-file-text-line';
|
||||
if (type?.endsWith('.zip') || type?.endsWith('.rar')) return 'ri-file-zip-line';
|
||||
return 'ri-file-line'; // icône générique
|
||||
};
|
||||
|
||||
const getFileIconColor = (file: File) => {
|
||||
const type = file?.name.toLowerCase();
|
||||
if (type?.endsWith('.pdf')) return 'var(--border-plain-error)'; // rouge
|
||||
if (type?.endsWith('.doc') || type?.endsWith('.docx')) return 'var(--border-plain-blue-france)'; // bleu
|
||||
if (type?.endsWith('.xls') || type?.endsWith('.xlsx')) return 'var(--border-plain-success)'; // vert
|
||||
if (type?.endsWith('.txt')) return 'var(--border-plain-grey)'; // gris
|
||||
if (type?.endsWith('.zip') || type?.endsWith('.rar')) return 'var(--illustration-color-850-tournesol-default)'; // jaune
|
||||
return 'var(--border-plain-blue-france)'; // bleu marine
|
||||
};
|
||||
|
||||
const borderColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'pending': return '2px solid var(--system-color-950-warning-active)';
|
||||
case 'completed': return '2px solid var(--system-color-950-success-active)';
|
||||
case 'error': return '2px solid var(--system-color-950-success-active)';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
|
||||
const getImagePreview = (file: File) => {
|
||||
return URL.createObjectURL(file);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="props.advanced"
|
||||
class="advanced-file"
|
||||
:title="`Fichier : ${props.file.name}`"
|
||||
:style="{border: borderColor}"
|
||||
:class="[styles['text-body-XS-mention-text-Medium']]"
|
||||
>
|
||||
<VButton
|
||||
tertiary
|
||||
noOutline
|
||||
size="sm"
|
||||
icon="ri-close-line"
|
||||
@click="onRemoveTemplatingFile(props.file, props.removeFileCallback, props.index)"
|
||||
title='Spprimer le fichier'
|
||||
ref="clearfile"
|
||||
aria-controls="clear-file"
|
||||
type="button"
|
||||
class="button"
|
||||
/>
|
||||
<div>
|
||||
<img
|
||||
v-if="file?.type.startsWith('image/')"
|
||||
role="presentation"
|
||||
:alt="props.file?.name"
|
||||
:src="getImagePreview(props.file)"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="getFileIconClass(props.file)"
|
||||
:style="{color: getFileIconColor(props.file), fontSize: '4rem', fontWeight: 500}"
|
||||
/>
|
||||
</div>
|
||||
<span :title="props.file?.name" class="file-name">
|
||||
{{ props.file?.name }}
|
||||
</span>
|
||||
<span>{{ formatSize(props.file?.size ?? 0) }}</span>
|
||||
<VBadge
|
||||
v-if="advanced"
|
||||
:label="badgeLabel"
|
||||
:type="badgeType"
|
||||
small
|
||||
no-icon
|
||||
/>
|
||||
<VProgressBar
|
||||
v-if="advanced && status === 'pending'"
|
||||
:value="props.progress"
|
||||
:show-value="false"
|
||||
style="width: 6rem; margin-top: 0.5rem;"
|
||||
/>
|
||||
</div>
|
||||
<!-- Simple file -->
|
||||
<div v-else class="simple-file" >
|
||||
<div>
|
||||
<img
|
||||
v-if="file?.type.startsWith('image/')"
|
||||
role="presentation"
|
||||
:alt="props.file.name"
|
||||
:src="getImagePreview(props.file)"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="getFileIconClass(props.file)"
|
||||
:style="{color: getFileIconColor(props.file), fontSize: '2.5rem', padding: '0px'}"
|
||||
/>
|
||||
</div>
|
||||
<div class="file-info" :class="[styles['text-body-SM-detail-text-Regular']]">
|
||||
<span>{{ props.file?.name }}</span>
|
||||
<span>{{ formatSize(props.file?.size ?? 0) }}</span>
|
||||
</div>
|
||||
<VBadge
|
||||
:label="badgeLabel"
|
||||
:type="badgeType"
|
||||
small
|
||||
no-icon
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
img{
|
||||
width: 2.5rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.button{
|
||||
display: flex;
|
||||
align-self: self-end;
|
||||
}
|
||||
|
||||
.advanced-file{
|
||||
border-radius: 3px;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.simple-file{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--p-fileupload-content-gap) / 4);
|
||||
}
|
||||
|
||||
.file-name{
|
||||
width: 6rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0rem 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-info{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
gap: 0.025rem;
|
||||
}
|
||||
</style>
|
362
src/components/file/VFileUpload.vue
Normal file
362
src/components/file/VFileUpload.vue
Normal file
|
@ -0,0 +1,362 @@
|
|||
<script setup lang="ts">
|
||||
import FileUpload from 'primevue/fileupload';
|
||||
import VHint from '../hint/VHint.vue';
|
||||
import VLabel from '../label/VLabel.vue';
|
||||
import VButton from '../button/VButton.vue';
|
||||
import VButtonGroup from '../button/VButtonGroup.vue';
|
||||
import VFile from './VFile.vue';
|
||||
import VMessage from '../message/VMessage.vue';
|
||||
import VScrollpanel from '../scrollpanel/VScrollpanel.vue';
|
||||
import VLabelErrorProxy from './VLabelErrorProxy.vue';
|
||||
import type IVFileUpload from './IVFileUpload.type';
|
||||
import type { FileUploadErrorEvent, FileUploadProgressEvent, FileUploadRemoveEvent, FileUploadSelectEvent, FileUploadUploadEvent } from 'primevue/fileupload';
|
||||
import { computed, useId, ref } from 'vue';
|
||||
import styles from '@visua/typography.module.css';
|
||||
|
||||
const fileUploadRef = ref();
|
||||
const fileProgressMap = ref<Record<string, number>>({});
|
||||
const hasActiveError = ref(false);
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<IVFileUpload>(), {
|
||||
id: () => useId(),
|
||||
label: 'Ajouter un fichier',
|
||||
validMessage: '',
|
||||
hint: '',
|
||||
disabled: false,
|
||||
invalidFileSizeMessage: 'Taille de fichier {0} invalide, la taille du fichier doit être plus petite que {1}',
|
||||
invalidFileLimitMessage: 'Nombre maximal des fichiers atteint, le nombre maximal est {0.}',
|
||||
invalidFileTypeMessage: 'Type de fichier invalide, le type de fichier {0} n\'est pas pris en charge',
|
||||
accept: undefined,
|
||||
chooseLabel: 'Choisir',
|
||||
name: undefined,
|
||||
url: undefined,
|
||||
maxFileSize: undefined,
|
||||
fileLimit: undefined,
|
||||
withCredentials: false,
|
||||
previewWidth: 50,
|
||||
uploadLabel: 'Téléverser',
|
||||
cancelLabel: 'Annuler',
|
||||
showCancelButton: true,
|
||||
showUploadButton: true,
|
||||
advanced: false
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'select',
|
||||
'before-upload',
|
||||
'progress',
|
||||
'upload',
|
||||
'error',
|
||||
'before-send',
|
||||
'clear',
|
||||
'remove',
|
||||
'removeUploadFile',
|
||||
'uploader',
|
||||
]);
|
||||
|
||||
defineExpose({
|
||||
upload: () => fileUploadRef.value.upload()
|
||||
});
|
||||
|
||||
const handleSelect = (event: FileUploadSelectEvent) => {
|
||||
emit('select', event);
|
||||
if (!props.advanced && event.files.length > 0 && fileUploadRef.value) {
|
||||
fileUploadRef.value.files = [event.files.at(-1)];
|
||||
fileUploadRef.value.uploadedFiles = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
emit('clear');
|
||||
hasActiveError.value = false;
|
||||
};
|
||||
|
||||
const handleUpload = (event: FileUploadUploadEvent) => {
|
||||
emit('upload', event);
|
||||
};
|
||||
|
||||
const handleRemove = (event: FileUploadRemoveEvent) => {
|
||||
emit('remove', event);
|
||||
hasActiveError.value =false;
|
||||
};
|
||||
|
||||
const handleProgress = (event: FileUploadProgressEvent) => {
|
||||
emit('progress', event);
|
||||
|
||||
const files = fileUploadRef.value?.files || [];
|
||||
files.forEach((file: File) => {
|
||||
fileProgressMap.value[file.name] = event.progress;
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = (event: FileUploadErrorEvent) => {
|
||||
emit('error', event);
|
||||
hasActiveError.value = true;
|
||||
if (!props.advanced && fileUploadRef.value) {
|
||||
fileUploadRef.value.uploadedFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
const lastSelectedFile = computed(() => {
|
||||
const files = fileUploadRef.value?.files || [];
|
||||
return files.at(-1) ?? null;
|
||||
});
|
||||
|
||||
const uploadEvent = (callback: () => void) => {
|
||||
callback();
|
||||
};
|
||||
|
||||
const padding = computed(() => props.advanced ? '1.125rem' : '0rem')
|
||||
const borderColor = computed(() => props.advanced ? 'var(--border-default-grey)' : 'transparent');
|
||||
|
||||
const labelState = computed(() => {
|
||||
if(!hasActiveError.value && !props.disabled) return 'default';
|
||||
else if(hasActiveError.value && !props.disabled) return 'error';
|
||||
else return undefined
|
||||
})
|
||||
|
||||
type MessageType = 'alert' | 'warning' | 'success' | 'info';
|
||||
|
||||
const globalStatusMessage = computed<{
|
||||
type: MessageType;
|
||||
title: string;
|
||||
} | null>(() => {
|
||||
const files = fileUploadRef.value?.files || [];
|
||||
const uploaded = fileUploadRef.value?.uploadedFiles || [];
|
||||
|
||||
const hasError = files.some((f: { status: string; }) => f.status === 'error');
|
||||
const hasPending = files.length > 0;
|
||||
|
||||
if (hasError) return { type: 'alert', title: 'Erreur lors du téléversement' };
|
||||
if (hasPending) return { type: 'warning', title: 'En attente de téléversement' };
|
||||
if (uploaded.length > 0) return { type: 'success', title: 'Tous les fichiers ont été téléversés' };
|
||||
return null;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container" data-testid="file-label">
|
||||
<VLabel
|
||||
:for="`file-upload-${props.id}`"
|
||||
:label="props.label"
|
||||
:disabled="props.disabled"
|
||||
:type="labelState"
|
||||
:hint="props.hint"
|
||||
/>
|
||||
<FileUpload
|
||||
ref="fileUploadRef"
|
||||
:id="`file-upload-${props.id}`"
|
||||
:multiple="props.advanced"
|
||||
:invalid-file-limit-message="props.invalidFileLimitMessage"
|
||||
:invalid-file-size-message="props.invalidFileSizeMessage"
|
||||
:invalid-file-type-message="props.invalidFileTypeMessage"
|
||||
mode="advanced"
|
||||
:accept="props.accept"
|
||||
:disabled="props.disabled"
|
||||
v-bind="$attrs"
|
||||
:auto="!props.advanced"
|
||||
:choose-label="props.chooseLabel"
|
||||
:upload-label="props.uploadLabel"
|
||||
:cancel-label="props.cancelLabel"
|
||||
:name="props.name"
|
||||
:url="props.url"
|
||||
:max-file-size="props.maxFileSize"
|
||||
:file-limit="props.fileLimit"
|
||||
:with-credentials="props.withCredentials"
|
||||
:preview-width="props.previewWidth"
|
||||
:show-cancel-button="props.showCancelButton"
|
||||
:show-upload-button="props.showUploadButton"
|
||||
@select="handleSelect($event)"
|
||||
@before-send="emit('before-send', $event)"
|
||||
@progress="handleProgress($event)"
|
||||
@before-upload="emit('before-upload', $event)"
|
||||
@uploader="emit('uploader', $event)"
|
||||
@upload="handleUpload($event)"
|
||||
@error="handleError($event)"
|
||||
@clear="handleClear()"
|
||||
@remove="handleRemove($event)"
|
||||
@remove-uploaded-file="emit('removeUploadFile', $event)"
|
||||
class="p-fileupload"
|
||||
>
|
||||
<template #header="slotProps">
|
||||
<VButtonGroup
|
||||
v-if="props.advanced"
|
||||
:buttons="[
|
||||
{
|
||||
label: 'Parcourir...',
|
||||
onClick: slotProps.chooseCallback,
|
||||
title: 'parcourir les fichiers',
|
||||
},
|
||||
{
|
||||
label: 'Téléverser',
|
||||
icon: 'ri-upload-cloud-line',
|
||||
onClick: () => uploadEvent(slotProps.uploadCallback),
|
||||
secondary: true,
|
||||
disabled: !slotProps.files || slotProps.files.length === 0,
|
||||
title: 'televerser les fichiers'
|
||||
},
|
||||
{
|
||||
label: 'Supprimer',
|
||||
tertiary: true,
|
||||
onClick: slotProps.clearCallback,
|
||||
disabled: !slotProps.files || slotProps.files.length === 0,
|
||||
title: 'suprimer les fichiers'
|
||||
}
|
||||
]"
|
||||
size="sm"
|
||||
:disabled="props.disabled"
|
||||
inlineLayoutWhen="always"
|
||||
title="boutons de gestion des fichiers"
|
||||
/>
|
||||
<div v-else class="simple">
|
||||
<VButton
|
||||
label="Parcourir..."
|
||||
:disabled="props.disabled"
|
||||
size="sm"
|
||||
@click="slotProps.chooseCallback"
|
||||
title="parcourir les fichiers"
|
||||
/>
|
||||
<span
|
||||
v-if="(!slotProps.files || slotProps.files.length === 0) && (!slotProps.uploadedFiles || slotProps.uploadedFiles.length === 0) && !hasActiveError"
|
||||
:class="[styles['text-body-SM-detail-text-Regular']]"
|
||||
>
|
||||
Aucun fichier sélectionné
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty v-if="props.advanced && !hasActiveError">
|
||||
<div class="fileupload-empty" :class="[styles['text-body-SM-detail-text-Regular']]">
|
||||
<i class="ri-upload-cloud-line upload-cloud-icon"></i>
|
||||
<p>Glissez-déposez les fichiers ici pour les téléverser.</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #content="slotProps">
|
||||
<div v-if="props.advanced && !hasActiveError" style="margin-top: 0.75rem;" class="file-content">
|
||||
<VMessage
|
||||
v-if="globalStatusMessage"
|
||||
:type="globalStatusMessage.type"
|
||||
:title="globalStatusMessage.title"
|
||||
/>
|
||||
<VScrollpanel height="20rem">
|
||||
<div class="file-area">
|
||||
<div v-if="slotProps.files.length > 0" class="file-non-uploaded-area">
|
||||
<VFile
|
||||
v-for="(file, index) in slotProps.files"
|
||||
:key="file.name + file.type + file.size"
|
||||
:file="file"
|
||||
:index="index"
|
||||
:remove-file-callback="slotProps.removeFileCallback"
|
||||
:progress="fileProgressMap[file.name] || 0"
|
||||
status="pending"
|
||||
advanced
|
||||
/>
|
||||
</div>
|
||||
<div v-if="slotProps.uploadedFiles.length > 0" class="file-uploaded-area">
|
||||
<VFile
|
||||
v-for="(file, index) in slotProps.uploadedFiles"
|
||||
:key="file.name + file.type + file.size"
|
||||
:file="file"
|
||||
:index="index"
|
||||
:remove-file-callback="slotProps.removeFileCallback"
|
||||
:progress="100"
|
||||
status="completed"
|
||||
advanced
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VScrollpanel>
|
||||
</div>
|
||||
<div v-if="!props.advanced && !hasActiveError">
|
||||
<VFile
|
||||
v-if="lastSelectedFile"
|
||||
:file="lastSelectedFile"
|
||||
:remove-file-callback="slotProps.removeFileCallback"
|
||||
status="pending"
|
||||
/>
|
||||
<VFile
|
||||
v-if="slotProps.uploadedFiles.length > 0 && slotProps.uploadedFiles.at(-1)"
|
||||
:file="slotProps.uploadedFiles.at(-1)!"
|
||||
:remove-file-callback="slotProps.removeFileCallback"
|
||||
status="completed"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<VHint
|
||||
v-for="message of slotProps.messages"
|
||||
:key="message"
|
||||
:title="message"
|
||||
type="alert"
|
||||
icon
|
||||
/>
|
||||
</div>
|
||||
<VLabelErrorProxy :hasError="(slotProps.messages ?? []).length > 0" @update:error="hasActiveError = $event" />
|
||||
</template>
|
||||
</FileUpload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
*{
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.p-fileupload{
|
||||
width: 100%;
|
||||
--p-fileupload-border-color: v-bind(borderColor);
|
||||
padding: v-bind(padding);
|
||||
}
|
||||
|
||||
.simple{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--p-fileupload-file-gap);
|
||||
}
|
||||
|
||||
.fileupload-empty{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-cloud-icon{
|
||||
font-size: 5rem;
|
||||
color: var(--border-contrast-grey);
|
||||
}
|
||||
|
||||
.file-content,
|
||||
.file-area{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-content{gap: 0.75rem;}
|
||||
.file-area{gap: 0.5rem;}
|
||||
|
||||
.file-non-uploaded-area,
|
||||
.file-uploaded-area {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.25rem;
|
||||
}
|
||||
</style>
|
27
src/components/file/VLabelErrorProxy.vue
Normal file
27
src/components/file/VLabelErrorProxy.vue
Normal file
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUpdated, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
hasError: boolean
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:error', value: boolean): void
|
||||
}>();
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:error', props.hasError);
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
emit('update:error', props.hasError);
|
||||
});
|
||||
|
||||
watch(() => props.hasError, (val) => {
|
||||
emit('update:error', val);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot/>
|
||||
</template>
|
35
src/components/scrollpanel/VScrollpanel.vue
Normal file
35
src/components/scrollpanel/VScrollpanel.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
step?: number
|
||||
height: string
|
||||
width?: string
|
||||
}>(), {
|
||||
step: 5,
|
||||
width: '100%'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollPanel
|
||||
:step="props.step"
|
||||
class="p-scrollpanel-content"
|
||||
:style="{width: props.width, maxHeight: props.height}"
|
||||
>
|
||||
<slot/>
|
||||
</ScrollPanel>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
*{
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.p-scrollpanel {
|
||||
height: fit-content;
|
||||
overflow-y: auto;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
</style>
|
45
test/VFileUpload.spec.ts
Normal file
45
test/VFileUpload.spec.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import VFileUpload from '../src/components/file/VFileUpload.vue'
|
||||
|
||||
describe('VFileUpload emits', () => {
|
||||
it('doit émettre les événements personnalisés', async () => {
|
||||
// Création des mocks
|
||||
const onSelect = vi.fn();
|
||||
const onUpload = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
const onClear = vi.fn();
|
||||
|
||||
const wrapper = mount(VFileUpload, {
|
||||
props: {
|
||||
advanced: true,
|
||||
onSelect,
|
||||
onUpload,
|
||||
onError,
|
||||
onRemove,
|
||||
onClear
|
||||
}
|
||||
});
|
||||
|
||||
// Simule une sélection de fichier
|
||||
await wrapper.vm.$emit('select', { files: [{ name: 'test.png' }] });
|
||||
expect(onSelect).toHaveBeenCalledWith({ files: [{ name: 'test.png' }] });
|
||||
|
||||
// Simule une erreur
|
||||
await wrapper.vm.$emit('error', { message: 'Erreur test' });
|
||||
expect(onError).toHaveBeenCalledWith({ message: 'Erreur test' });
|
||||
|
||||
// Simule un upload
|
||||
await wrapper.vm.$emit('upload', { files: ['fichier1'] });
|
||||
expect(onUpload).toHaveBeenCalledWith({ files: ['fichier1'] });
|
||||
|
||||
// Simule une suppression
|
||||
await wrapper.vm.$emit('remove', { file: 'test.png' });
|
||||
expect(onRemove).toHaveBeenCalledWith({ file: 'test.png' });
|
||||
|
||||
// Simule un clear
|
||||
await wrapper.vm.$emit('clear');
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user