Merge branch 'modal' into 'main'

Modal

See merge request cellule-financiere-pmo/design-system/visua-vue!12
This commit is contained in:
Paul Valerie GOMA 2025-07-28 09:36:36 +00:00
commit 88f6ee8fef
14 changed files with 560 additions and 6 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.12] - 2025-07-28
### Added
- Modal compoenent
- Confirm modal component
- useConfirmModal composable
## [1.0.11] - 2025-07-27 ## [1.0.11] - 2025-07-27
### Added ### Added
- Alert compoenent - Alert compoenent

View File

@ -1,3 +1,3 @@
# visua-vue # visua-vue
**Current version: v1.0.11** **Current version: v1.0.12**

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@cellule-financiere-pmo/visua-vue", "name": "@cellule-financiere-pmo/visua-vue",
"version": "1.0.11", "version": "1.0.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cellule-financiere-pmo/visua-vue", "name": "@cellule-financiere-pmo/visua-vue",
"version": "1.0.11", "version": "1.0.12",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@cellule-financiere-pmo/visua": "1.1.3", "@cellule-financiere-pmo/visua": "1.1.3",

View File

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

View File

@ -10,7 +10,9 @@
// import VProgressBarView from '../template/VProgressBarView.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'; // import VFileUploadView from '../template/VFileUploadView.vue';
import VAlertView from '../template/VAlertView.vue'; // import VAlertView from '../template/VAlertView.vue';
// import VModalView from '../template/VModalView.vue';
import VConfirmModalView from '../template/VConfirmModalView.vue';
</script> </script>
@ -26,5 +28,7 @@ import VAlertView from '../template/VAlertView.vue';
<!-- <VProgressBarView/> --> <!-- <VProgressBarView/> -->
<!-- <VMessageView/> --> <!-- <VMessageView/> -->
<!-- <VFileUploadView/> --> <!-- <VFileUploadView/> -->
<VAlertView/> <!-- <VAlertView/> -->
<!-- <VModalView/> -->
<VConfirmModalView/>
</template> </template>

View File

@ -16,3 +16,5 @@
@import './primevue-style/fileupload.css'; @import './primevue-style/fileupload.css';
@import './primevue-style/scrollpanel.css'; @import './primevue-style/scrollpanel.css';
@import './primevue-style/toast.css'; @import './primevue-style/toast.css';
@import './primevue-style/dialog.css';
@import './primevue-style/confirmdialog.css';

View File

@ -0,0 +1,5 @@
:root {
--p-confirmdialog-content-gap: 1rem;
--p-confirmdialog-icon-size: var(--titles-H6-XXS-size);
--p-confirmdialog-icon-color: var(--text-title-grey);
}

View File

@ -0,0 +1,14 @@
:root{
--p-dialog-background: var(--background-lifted-grey);
--p-dialog-border-color: var(--border-default-grey);
--p-dialog-color: var(--text-title-grey);
--p-dialog-border-radius: 0px;
--p-dialog-shadow: var(--shadow);
--p-dialog-header-padding: 1rem;
--p-dialog-header-gap: 0.5rem;
--p-dialog-title-font-size: var(--titles-H4-SM-size);
--p-dialog-title-font-weight: var( --titles-H4-SM-weight);
--p-dialog-content-padding: 0 1rem 1rem 1rem;
--p-dialog-footer-padding: 1rem;
--p-dialog-footer-gap: 1rem;
}

View File

@ -0,0 +1,42 @@
import type { ConfirmationOptions } from "primevue/confirmationoptions";
import { useConfirm } from "primevue";
import VButton from "../button/VButton.vue";
export function useConfirmModal() {
const confirm = useConfirm();
const showConfirmModal = ({
acceptProps = VButton,
rejectProps = VButton,
group = '',
header = '',
message = '',
icon = '',
accept = Function,
reject = Function,
onHide = Function,
onShow = Function,
modal = true,
blockScroll = true,
position = 'center',
appendTo = 'body',
}: ConfirmationOptions) => {
confirm.require({
group,
header,
message,
icon,
rejectProps,
acceptProps,
accept,
reject,
onHide,
onShow,
modal,
blockScroll,
position,
appendTo,
})
}
return {showConfirmModal}
}

View File

@ -0,0 +1,205 @@
import type { HTMLAttributes } from 'vue';
import type IVButton from '../button/IVButton.type';
import { type DialogBreakpoints } from 'primevue';
import type { HintedString } from '@primevue/core';
/**
* Interface representing the properties for a Modal component.
*/
export interface IModal {
/**
* Optional unique identifier for the modal element.
*/
modalId?: string
/**
* Flag indicating whether the modal is currently open.
*/
opened?: boolean
/**
* List of actions (buttons) displayed in the modal.
*/
actions?: IVButton[]
/**
* Determines if the modal should behave like an alert dialog.
*/
isAlert?: boolean
/**
* Reference to the originating element that triggered the modal,
* used to return focus when the modal is closed.
*/
origin?: { focus: () => void }
/**
* Title displayed at the top of the modal.
*/
title: string
/**
* Optional icon name or path to render next to the title.
*/
icon?: string
/**
* Defines the modal size: 'sm' = small, 'md' = medium, 'lg' = large.
*/
size?: 'sm' | 'md' | 'lg'
/**
* Accessible label (aria-label) for the close button.
*/
closeButtonLabel?: string
/**
* Tooltip (title attribute) shown when hovering over the close button.
*/
closeButtonTitle?: string
}
/**
* Defines valid properties in Dialog component.
*/
export interface DialogProps {
/**
* Title content of the dialog.
*/
header?: string | undefined;
/**
* Footer content of the dialog.
*/
footer?: string | undefined;
/**
* Specifies the visibility of the dialog.
* @defaultValue false
*/
visible?: boolean | undefined;
/**
* Defines if background should be blocked when dialog is displayed.
* @defaultValue false
*/
modal?: boolean | undefined;
/**
* Style of the content section.
*/
contentStyle?: unknown;
/**
* Style class of the content section.
*/
contentClass?: unknown;
/**
* Used to pass all properties of the HTMLDivElement to the overlay Dialog inside the component.
*/
contentProps?: HTMLAttributes | undefined;
/**
* Adds a close icon to the header to hide the dialog.
* @defaultValue true
*/
closable?: boolean | undefined;
/**
* Specifies if clicking the modal background should hide the dialog.
* @defaultValue false
*/
dismissableMask?: boolean | undefined;
/**
* Specifies if pressing escape key should hide the dialog.
* @defaultValue true
*/
closeOnEscape?: boolean | undefined;
/**
* Whether to show the header or not.
* @defaultValue true
*/
showHeader?: boolean | undefined;
/**
* Whether background scroll should be blocked when dialog is visible.
* @defaultValue false
*/
blockScroll?: boolean | undefined;
/**
* Base zIndex value to use in layering.
* @defaultValue 0
*/
baseZIndex?: number | undefined;
/**
* Whether to automatically manage layering.
* @defaultValue true
*/
autoZIndex?: boolean | undefined;
/**
* Position of the dialog.
* @defaultValue center
*/
position?: HintedString<'center' | 'top' | 'bottom' | 'left' | 'right' | 'topleft' | 'topright' | 'bottomleft' | 'bottomright'> | undefined;
/**
* Whether the dialog can be displayed full screen.
* @defaultValue false
*/
maximizable?: boolean | undefined;
/**
* Object literal to define widths per screen size.
*/
breakpoints?: DialogBreakpoints;
/**
* Enables dragging to change the position using header.
* @defaultValue true
*/
draggable?: boolean | undefined;
/**
* Keeps dialog in the viewport when dragging.
* @defaultValue true
*/
keepInViewport?: boolean | undefined;
/**
* Minimum value for the left coordinate of dialog in dragging.
* @defaultValue 0.
*/
minX?: number | undefined;
/**
* Minimum value for the top coordinate of dialog in dragging.
* @defaultValue 0
*/
minY?: number | undefined;
/**
* A valid query selector or an HTMLElement to specify where the dialog gets attached.
* @defaultValue body
*/
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement;
/**
* Icon to display in the dialog close button.
*/
closeIcon?: string | undefined;
/**
* Icon to display in the dialog maximize button when dialog is not maximized.
*/
maximizeIcon?: string | undefined;
/**
* Icon to display in the dialog maximize button when dialog is minimized.
*/
minimizeIcon?: string | undefined;
/**
* Used to pass all properties of the ButtonProps to the Button component.
* @type {ButtonProps}
* @defaultValue { severity: 'secondary', rounded: true, text: true }
*/
closeButtonProps?: object | undefined;
/**
* Used to pass all properties of the ButtonProps to the Button component.
* @type {ButtonProps}
* @defaultValue { severity: 'secondary', rounded: true, text: true }
*/
maximizeButtonProps?: object | undefined;
}
/**
* Extended Modal interface that includes configuration for responsive breakpoints.
*/
export default interface IVModal extends Partial<Omit<IModal, 'opened'>>, Partial<DialogProps> {
/**
* Breakpoints used to control responsive modal behavior.
* Compatible with PrimeVue Dialog breakpoints format.
*/
breakpoints?: DialogBreakpoints
}

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import ConfirmDialog from 'primevue/confirmdialog';
import type { ConfirmDialogProps } from 'primevue/confirmdialog';
import VButtonGroup from '../button/VButtonGroup.vue';
import styles from '@visua/typography.module.css';
const props = withDefaults(defineProps<ConfirmDialogProps>(), {
group: '',
draggable: true,
breakpoints: undefined,
})
</script>
<template>
<ConfirmDialog
:group="props.group"
:draggable="props.draggable"
:breakpoints="props.breakpoints"
role="alert"
>
<template #container="slotProps">
<div class="container">
<div
class='header'
:class="[styles['titles-H6-XXS']]"
:style="('danger' in slotProps.message.acceptProps) ? {color: 'var(--text-default-error)'} : {color: 'var(--text-default-warning)'}"
>
<i :class="slotProps.message.icon" style="font-weight:lighter;"></i>
<span style="width: 100%;">{{ slotProps.message.header }}</span>
</div>
<span>{{ slotProps.message.message }}</span>
<VButtonGroup
:title="slotProps.message.group"
inline-layout-when="always"
size="sm"
align="right"
:buttons="[
{
...slotProps.message.acceptProps,
onclick: slotProps.acceptCallback
},
{
...slotProps.message.rejectProps,
onclick: slotProps.rejectCallback
}
]"
/>
</div>
</template>
</ConfirmDialog>
</template>
<style lang="css" scoped>
.container{
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.header{
display: flex;
flex-direction: row;
gap: var(--p-dialog-header-gap);
align-items: baseline;
}
</style>

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog';
import type IVModal from './IVModal.type';
import { useId, computed, watch, ref } from 'vue';
import VButtonGroup from '../button/VButtonGroup.vue';
import VButton from '../button/VButton.vue';
import styles from '@visua/typography.module.css';
const props = withDefaults(defineProps<IVModal>(), {
modalId: () => useId(),
actions: () => [],
icon: undefined,
size: 'md',
closeButtonLabel: 'Fermer',
closeButtonTitle: 'Fermer la fenêtre modale',
title: undefined,
visible: false,
isAlert: false,
breakpoints: undefined,
modal: true,
dismissableMask: false,
blockScroll: true,
position: 'center',
maximizable: false,
draggable: true,
showHeader: true,
closeOnEscape: true,
})
const role = computed(() => {
return props.isAlert ? 'alertdialog' : 'dialog'
})
const modalSize = computed(() => {
switch (props.size) {
case 'lg':
return `width: 49.5rem;`
case 'md':
return `width: 36.75rem;`
case 'sm':
return `width: 24rem;`
default:
return `width: 36.75rem;`
}
})
const emit = defineEmits([
'update:visible',
'hide',
'after-hide',
'show',
'maximize',
'unmaximize',
'dragstart',
'dragend'
]);
const localVisible = ref(props.visible);
watch(() => props.visible, (newVal) => {
if(localVisible.value !== newVal){
localVisible.value = newVal;
}
})
watch(localVisible, (newVal) => {
if(props.visible !== newVal){
emit('update:visible', newVal);
}
});
</script>
<template>
<Dialog
:header="props.title"
id="modal"
:modal="props.modal"
:dismissable-mask="props.dismissableMask"
:role="role"
:aria-model="true"
:aria-labelledby="`modal-${props.modalId}-dialog`"
ref="modal"
:style="modalSize"
:breakpoints="props.breakpoints"
:block-scroll="props.blockScroll"
:position="props.position"
:maximizable="props.maximizable"
:draggable="props.draggable"
:show-header="props.showHeader"
:close-on-escape="props.closeOnEscape"
v-model:visible="localVisible"
@update:visible="emit('update:visible', $event)"
@after-hide="emit('after-hide', $event)"
@dragend="emit('dragend', $event)"
@dragstart="emit('dragstart', $event)"
@hide="emit('hide', $event)"
@maximize="emit('maximize', $event)"
@unmaximize="emit('unmaximize', $event)"
@show="emit('show', $event)"
>
<template #header>
<slot name="header" v-if="props.icon !== undefined">
<span :class="[styles['titles-H6-XXS']]">
<i :class="props.icon"></i>
{{ props.title }}
</span>
</slot>
</template>
<slot name="content"/>
<template #footer>
<slot name="footer">
<VButtonGroup
v-if="props.actions?.length"
inline-layout-when="always"
align="right"
:buttons="props.actions"
size="sm"
title="groupe de boutons"
/>
</slot>
</template>
<template #closebutton="{closeCallback}">
<VButton
tertiary
noOutline
size="sm"
icon="ri-close-line"
iconRight
@click="closeCallback"
:label="closeButtonLabel"
:title="closeButtonTitle"
ref="closeBtn"
aria-controls="modal-1"
type="button"
/>
</template>
</Dialog>
</template>

View File

@ -3,10 +3,12 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import primeVue from 'primevue/config' import primeVue from 'primevue/config'
import ToastService from 'primevue/toastservice' import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
const app = createApp(App) const app = createApp(App)
app.use(primeVue) app.use(primeVue)
app.use(ToastService) app.use(ToastService)
app.use(ConfirmationService)
app.mount('#app') app.mount('#app')

69
test/VModal.spec.ts Normal file
View File

@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import VModal from '../src/components/modal/VModal.vue';
import type IVButton from '../src/components/button/IVButton.type';
describe('VModal.vue', () => {
let actions: IVButton[];
let visible: boolean;
beforeEach(() => {
visible = true;
actions = [
{
label: 'Bouton primaire',
onClick: vi.fn(),
title: 'primaire',
},
{
label: 'Bouton secondaire',
secondary: true,
onClick: vi.fn(),
title: 'secondaire',
},
{
label: 'Bouton tertiaire',
tertiary: true,
onClick: vi.fn(),
title: 'tertiaire',
},
];
});
it('emits update:visible when internal visibility changes', async () => {
const wrapper = mount(VModal, {
props: {
visible: true,
actions,
},
});
// Simulation dun changement interne
await wrapper.vm.$emit('update:visible', false);
await nextTick();
const emitted = wrapper.emitted('update:visible');
expect(emitted).toBeTruthy();
expect(emitted?.[0]).toEqual([false]);
});
it('emits lifecycle events (show/hide)', async () => {
const wrapper = mount(VModal, {
props: {
visible,
actions,
},
});
wrapper.vm.$emit('show');
wrapper.vm.$emit('hide');
await nextTick();
expect(wrapper.emitted('show')).toBeTruthy();
expect(wrapper.emitted('hide')).toBeTruthy();
});
});