Files
memory-infrastructure-palace/code/websites/pokedex.online/src/components/shared/BaseModal.vue

364 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="modelValue"
class="modal-overlay"
:class="{ 'modal-overlay--persistent': persistent }"
@click="handleOverlayClick"
>
<div
ref="modalRef"
class="modal-container"
:class="`modal-container--${size}`"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? 'modal-title' : undefined"
@click.stop
>
<!-- Header -->
<div v-if="title || $slots.header || showClose" class="modal-header">
<slot name="header">
<h2 v-if="title" id="modal-title" class="modal-title">
{{ title }}
</h2>
</slot>
<button
v-if="showClose"
type="button"
class="modal-close"
aria-label="Close modal"
@click="handleClose"
>
×
</button>
</div>
<!-- Body -->
<div class="modal-body">
<slot></slot>
</div>
<!-- Footer -->
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
const props = defineProps({
/**
* Whether the modal is visible
*/
modelValue: {
type: Boolean,
required: true
},
/**
* Modal title (alternative to header slot)
*/
title: {
type: String,
default: null
},
/**
* Modal size
* @type {'small' | 'medium' | 'large' | 'full'}
*/
size: {
type: String,
default: 'medium',
validator: value => ['small', 'medium', 'large', 'full'].includes(value)
},
/**
* Whether clicking overlay closes the modal
*/
closeOnOverlay: {
type: Boolean,
default: true
},
/**
* Whether modal can be closed (hides X button, prevents ESC/overlay close)
*/
persistent: {
type: Boolean,
default: false
},
/**
* Whether to show the close X button
*/
showClose: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:modelValue', 'close', 'open']);
const modalRef = ref(null);
const previousActiveElement = ref(null);
// Close handlers
const handleClose = () => {
if (!props.persistent) {
emit('update:modelValue', false);
emit('close');
}
};
const handleOverlayClick = () => {
if (props.closeOnOverlay && !props.persistent) {
handleClose();
}
};
const handleEscapeKey = event => {
if (event.key === 'Escape' && props.modelValue && !props.persistent) {
handleClose();
}
};
// Focus management
const getFocusableElements = () => {
if (!modalRef.value) return [];
const focusableSelectors = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
];
return Array.from(
modalRef.value.querySelectorAll(focusableSelectors.join(','))
);
};
const handleTabKey = event => {
if (!props.modelValue) return;
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab: move focus backwards
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: move focus forwards
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
const handleKeyDown = event => {
if (event.key === 'Escape') {
handleEscapeKey(event);
} else if (event.key === 'Tab') {
handleTabKey(event);
}
};
// Lifecycle hooks
watch(
() => props.modelValue,
async newValue => {
if (newValue) {
// Modal opened
previousActiveElement.value = document.activeElement;
document.body.style.overflow = 'hidden';
emit('open');
// Focus first focusable element after render
await nextTick();
const focusableElements = getFocusableElements();
if (focusableElements.length > 0) {
focusableElements[0].focus();
} else if (modalRef.value) {
modalRef.value.focus();
}
} else {
// Modal closed
document.body.style.overflow = '';
// Restore focus to previous element
if (previousActiveElement.value) {
previousActiveElement.value.focus();
previousActiveElement.value = null;
}
}
}
);
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
});
</script>
<style scoped>
/* Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
overflow-y: auto;
}
.modal-overlay--persistent {
cursor: not-allowed;
}
/* Container */
.modal-container {
background: white;
border-radius: 0.5rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-height: 90vh;
display: flex;
flex-direction: column;
position: relative;
cursor: auto;
}
.modal-container--small {
max-width: 400px;
width: 100%;
}
.modal-container--medium {
max-width: 600px;
width: 100%;
}
.modal-container--large {
max-width: 900px;
width: 100%;
}
.modal-container--full {
max-width: 95vw;
width: 100%;
height: 90vh;
}
/* Header */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
margin-left: 1rem;
background: transparent;
border: none;
border-radius: 0.25rem;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
}
.modal-close:hover {
background-color: #f3f4f6;
color: #1f2937;
}
.modal-close:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Body */
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1 1 auto;
}
/* Footer */
.modal-footer {
padding: 1.25rem 1.5rem;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
flex-shrink: 0;
}
/* Transitions */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .modal-container,
.modal-leave-active .modal-container {
transition: transform 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
transform: scale(0.95);
}
</style>