EDMS/components/base/BaseModal.vue
2025-06-05 14:57:08 +08:00

322 lines
8.6 KiB
Vue

<script setup>
import { computed, watch, onMounted, onUnmounted } from 'vue';
import { useDesignSystem } from '~/composables/useDesignSystem';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', 'full'].includes(value)
},
persistent: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
scrollable: {
type: Boolean,
default: true
},
centered: {
type: Boolean,
default: false
},
hideHeader: {
type: Boolean,
default: false
},
hideFooter: {
type: Boolean,
default: false
},
maxHeight: {
type: String,
default: '80vh'
}
});
const emit = defineEmits(['close', 'confirm', 'cancel']);
// Design system
const { tokens, utils, animations } = useDesignSystem();
// Modal state
const isVisible = ref(false);
const modalRef = ref(null);
const focusableElements = ref([]);
// Computed classes
const modalClasses = computed(() => {
return utils.composeClasses(
'fixed inset-0 z-50 flex items-center justify-center p-4',
centered.value ? 'items-center' : 'items-start pt-16',
'backdrop-blur-sm bg-black/50'
);
});
const dialogClasses = computed(() => {
return utils.composeClasses(
'relative w-full max-h-full',
utils.getSizeClasses('modal', props.size),
'bg-white dark:bg-gray-800',
'rounded-lg shadow-xl',
'transform transition-all duration-300',
'border border-gray-200 dark:border-gray-600'
);
});
const contentClasses = computed(() => {
return utils.composeClasses(
'flex flex-col max-h-full',
props.scrollable ? 'overflow-hidden' : ''
);
});
// Focus management
const getFocusableElements = () => {
if (!modalRef.value) return [];
const selectors = [
'button:not([disabled])',
'input:not([disabled])',
'textarea:not([disabled])',
'select:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
];
return Array.from(modalRef.value.querySelectorAll(selectors.join(', ')));
};
const trapFocus = (event) => {
if (!isVisible.value) return;
const focusableEls = getFocusableElements();
const firstFocusable = focusableEls[0];
const lastFocusable = focusableEls[focusableEls.length - 1];
if (event.key === 'Tab') {
if (event.shiftKey) {
if (document.activeElement === firstFocusable) {
event.preventDefault();
lastFocusable?.focus();
}
} else {
if (document.activeElement === lastFocusable) {
event.preventDefault();
firstFocusable?.focus();
}
}
}
};
const handleEscape = (event) => {
if (event.key === 'Escape' && isVisible.value && !props.persistent) {
closeModal();
}
};
// Modal actions
const closeModal = () => {
if (props.loading) return;
emit('close');
};
const handleBackdropClick = (event) => {
if (event.target === event.currentTarget && !props.persistent) {
closeModal();
}
};
const confirmAction = () => {
emit('confirm');
};
const cancelAction = () => {
emit('cancel');
};
// Watchers
watch(() => props.visible, (newValue) => {
isVisible.value = newValue;
if (newValue) {
document.body.style.overflow = 'hidden';
nextTick(() => {
const focusableEls = getFocusableElements();
focusableEls[0]?.focus();
});
} else {
document.body.style.overflow = '';
}
});
// Lifecycle
onMounted(() => {
document.addEventListener('keydown', handleEscape);
document.addEventListener('keydown', trapFocus);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', trapFocus);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<Transition
:enter-active-class="animations.transitions.fadeIn.enter"
:enter-from-class="animations.transitions.fadeIn.enterFrom"
:enter-to-class="animations.transitions.fadeIn.enterTo"
:leave-active-class="animations.transitions.fadeIn.leave"
:leave-from-class="animations.transitions.fadeIn.leaveFrom"
:leave-to-class="animations.transitions.fadeIn.leaveTo"
>
<div
v-if="isVisible"
:class="modalClasses"
@click="handleBackdropClick"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? 'modal-title' : undefined"
>
<Transition
:enter-active-class="animations.transitions.scaleIn.enter"
:enter-from-class="animations.transitions.scaleIn.enterFrom"
:enter-to-class="animations.transitions.scaleIn.enterTo"
:leave-active-class="animations.transitions.scaleIn.leave"
:leave-from-class="animations.transitions.scaleIn.leaveFrom"
:leave-to-class="animations.transitions.scaleIn.leaveTo"
>
<div
v-if="isVisible"
ref="modalRef"
:class="dialogClasses"
@click.stop
>
<!-- Loading Overlay -->
<div
v-if="loading"
class="absolute inset-0 bg-white/80 dark:bg-gray-800/80 rounded-lg flex items-center justify-center z-10"
>
<div class="flex flex-col items-center space-y-3">
<div class="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
<div :class="contentClasses" :style="{ maxHeight: maxHeight }">
<!-- Header -->
<header
v-if="!hideHeader && (title || $slots.header)"
class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-600"
>
<div class="flex-1">
<slot name="header">
<h2
v-if="title"
id="modal-title"
class="text-lg font-semibold text-gray-900 dark:text-gray-100"
>
{{ title }}
</h2>
</slot>
</div>
<button
v-if="!persistent"
@click="closeModal"
:disabled="loading"
class="ml-4 p-1 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200 disabled:opacity-50"
aria-label="Close modal"
>
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</header>
<!-- Body -->
<main
:class="[
'flex-1',
scrollable ? 'overflow-y-auto' : '',
$slots.footer || !hideFooter ? 'p-6' : 'p-6 pb-6'
]"
>
<slot name="body">
<slot />
</slot>
</main>
<!-- Footer -->
<footer
v-if="!hideFooter && ($slots.footer || $slots.actions)"
class="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50"
>
<slot name="footer">
<slot name="actions">
<rs-button
variant="secondary-outline"
@click="cancelAction"
:disabled="loading"
>
Cancel
</rs-button>
<rs-button
variant="primary"
@click="confirmAction"
:disabled="loading"
>
Confirm
</rs-button>
</slot>
</slot>
</footer>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* Enhanced scrollbar for modal content */
main::-webkit-scrollbar {
width: 6px;
}
main::-webkit-scrollbar-track {
background: transparent;
}
main::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
main::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
/* Dark mode scrollbar */
.dark main::-webkit-scrollbar-thumb {
background-color: rgba(75, 85, 99, 0.5);
}
.dark main::-webkit-scrollbar-thumb:hover {
background-color: rgba(75, 85, 99, 0.7);
}
</style>