generated from corrad-software/corrad-af-2024
322 lines
8.6 KiB
Vue
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> |