Merge branch 'file' into 'main'

File

See merge request cellule-financiere-pmo/design-system/visua-vue!10
This commit is contained in:
Paul Valerie GOMA 2025-07-27 02:11:25 +00:00
commit 2c91cfb38c
14 changed files with 951 additions and 10 deletions

View File

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

View File

@ -1,3 +1,3 @@
# visua-vue
**Current version: v1.0.9**
**Current version: v1.0.10**

4
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@cellule-financiere-pmo/visua-vue",
"version": "1.0.9",
"version": "1.0.10",
"type": "module",
"scripts": {
"dev": "vite",

View File

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

View File

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

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

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

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

View 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>

View 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>

View 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>

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