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

408 lines
11 KiB
Vue

<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useDesignSystem } from '~/composables/useDesignSystem';
const props = defineProps({
// Layout type
layout: {
type: String,
default: 'default',
validator: (value) => ['default', 'sidebar', 'dashboard', 'fullscreen', 'split'].includes(value)
},
// Responsive behavior
adaptive: {
type: Boolean,
default: true
},
// Mobile behavior
mobileCollapse: {
type: Boolean,
default: true
},
// Padding/spacing
padding: {
type: String,
default: 'md',
validator: (value) => ['none', 'sm', 'md', 'lg', 'xl'].includes(value)
},
// Maximum width
maxWidth: {
type: String,
default: 'full',
validator: (value) => ['sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', 'full'].includes(value)
},
// Center content
centered: {
type: Boolean,
default: false
},
// Mobile navigation
showMobileNav: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['breakpoint-change', 'mobile-nav-toggle']);
// Design system
const { tokens, utils, layouts } = useDesignSystem();
// Responsive state
const currentBreakpoint = ref('lg');
const isMobile = ref(false);
const isTablet = ref(false);
const isDesktop = ref(true);
const mobileNavOpen = ref(false);
// Breakpoints
const breakpoints = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536
};
// Update responsive state
const updateBreakpoint = () => {
const width = window.innerWidth;
let newBreakpoint = 'sm';
if (width >= breakpoints['2xl']) newBreakpoint = '2xl';
else if (width >= breakpoints.xl) newBreakpoint = 'xl';
else if (width >= breakpoints.lg) newBreakpoint = 'lg';
else if (width >= breakpoints.md) newBreakpoint = 'md';
else newBreakpoint = 'sm';
if (newBreakpoint !== currentBreakpoint.value) {
currentBreakpoint.value = newBreakpoint;
emit('breakpoint-change', newBreakpoint);
}
isMobile.value = width < breakpoints.md;
isTablet.value = width >= breakpoints.md && width < breakpoints.lg;
isDesktop.value = width >= breakpoints.lg;
};
// Computed classes
const containerClasses = computed(() => {
const classes = ['responsive-container'];
// Layout classes
switch (props.layout) {
case 'sidebar':
classes.push(isMobile.value ? 'layout-mobile' : 'layout-sidebar');
break;
case 'dashboard':
classes.push('layout-dashboard');
break;
case 'fullscreen':
classes.push('layout-fullscreen');
break;
case 'split':
classes.push(isMobile.value ? 'layout-mobile' : 'layout-split');
break;
default:
classes.push('layout-default');
}
// Responsive classes
classes.push(`breakpoint-${currentBreakpoint.value}`);
if (isMobile.value) classes.push('is-mobile');
if (isTablet.value) classes.push('is-tablet');
if (isDesktop.value) classes.push('is-desktop');
// Padding classes
const paddingMap = {
none: '',
sm: 'p-2 md:p-4',
md: 'p-4 md:p-6',
lg: 'p-6 md:p-8',
xl: 'p-8 md:p-12'
};
if (paddingMap[props.padding]) {
classes.push(paddingMap[props.padding]);
}
// Max width classes
if (props.maxWidth !== 'full') {
classes.push(`max-w-${props.maxWidth}`);
}
// Centering
if (props.centered) {
classes.push('mx-auto');
}
return classes.join(' ');
});
// Mobile navigation toggle
const toggleMobileNav = () => {
mobileNavOpen.value = !mobileNavOpen.value;
emit('mobile-nav-toggle', mobileNavOpen.value);
};
// Lifecycle
onMounted(() => {
updateBreakpoint();
window.addEventListener('resize', updateBreakpoint);
});
onUnmounted(() => {
window.removeEventListener('resize', updateBreakpoint);
});
// Expose reactive state
defineExpose({
currentBreakpoint,
isMobile,
isTablet,
isDesktop,
mobileNavOpen,
toggleMobileNav
});
</script>
<template>
<div :class="containerClasses">
<!-- Mobile Navigation Header -->
<header
v-if="isMobile && showMobileNav"
class="mobile-nav-header sticky top-0 z-40 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-600 p-4"
>
<div class="flex items-center justify-between">
<slot name="mobile-title">
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
DMS Mobile
</h1>
</slot>
<button
@click="toggleMobileNav"
class="p-2 rounded-md text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Toggle navigation"
>
<Icon
:name="mobileNavOpen ? 'mdi:close' : 'mdi:menu'"
class="w-6 h-6"
/>
</button>
</div>
</header>
<!-- Mobile Navigation Drawer -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="transform -translate-x-full"
enter-to-class="transform translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="transform translate-x-0"
leave-to-class="transform -translate-x-full"
>
<aside
v-if="isMobile && mobileNavOpen"
class="fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-600 shadow-lg"
>
<div class="h-full flex flex-col">
<div class="flex-1 overflow-y-auto p-4">
<slot name="mobile-nav">
<!-- Default mobile navigation content -->
<nav class="space-y-2">
<a href="#" class="block p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Dashboard
</a>
<a href="#" class="block p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Documents
</a>
<a href="#" class="block p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Settings
</a>
</nav>
</slot>
</div>
</div>
</aside>
</Transition>
<!-- Mobile Navigation Overlay -->
<div
v-if="isMobile && mobileNavOpen"
class="fixed inset-0 z-40 bg-black/50"
@click="toggleMobileNav"
></div>
<!-- Main Content -->
<main
class="main-content"
:class="{
'ml-0': isMobile || !mobileNavOpen,
'transition-all duration-300': props.adaptive
}"
>
<!-- Desktop Sidebar -->
<aside
v-if="layout === 'sidebar' && !isMobile"
class="sidebar-desktop fixed left-0 top-0 z-30 h-full w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-600"
>
<slot name="sidebar">
<!-- Default sidebar content -->
<div class="p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Navigation
</h2>
<nav class="space-y-2">
<a href="#" class="block p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Dashboard
</a>
<a href="#" class="block p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Documents
</a>
</nav>
</div>
</slot>
</aside>
<!-- Content Area -->
<div
class="content-area"
:class="{
'ml-64': layout === 'sidebar' && !isMobile,
'grid grid-cols-1 lg:grid-cols-2 gap-6': layout === 'split' && !isMobile,
'min-h-screen': layout === 'fullscreen'
}"
>
<!-- Split Layout - Left Panel -->
<section v-if="layout === 'split' && !isMobile" class="split-left">
<slot name="split-left">
<div class="h-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Left Panel
</h3>
<p class="text-gray-600 dark:text-gray-400">
Left panel content goes here.
</p>
</div>
</slot>
</section>
<!-- Main Content / Split Layout - Right Panel -->
<section
class="main-section"
:class="{
'split-right': layout === 'split' && !isMobile
}"
>
<slot name="default">
<!-- Default content -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Main Content
</h2>
<p class="text-gray-600 dark:text-gray-400">
Your main content goes here. This container adapts to different screen sizes.
</p>
</div>
</slot>
</section>
</div>
</main>
<!-- Responsive Debug Info (Development only) -->
<div
v-if="process.env.NODE_ENV === 'development'"
class="fixed bottom-4 right-4 z-50 bg-black/80 text-white text-xs p-2 rounded-md font-mono"
>
<div>Breakpoint: {{ currentBreakpoint }}</div>
<div>Width: {{ isMobile ? 'Mobile' : isTablet ? 'Tablet' : 'Desktop' }}</div>
<div>Layout: {{ layout }}</div>
</div>
</div>
</template>
<style scoped>
.responsive-container {
@apply min-h-screen;
}
/* Layout-specific styles */
.layout-sidebar {
@apply flex;
}
.layout-dashboard {
@apply grid grid-cols-1 lg:grid-cols-12 gap-6;
}
.layout-fullscreen {
@apply h-screen overflow-hidden;
}
.layout-split {
@apply grid grid-cols-1 lg:grid-cols-2 gap-6;
}
.layout-mobile {
@apply flex flex-col;
}
/* Mobile-specific adjustments */
.is-mobile .sidebar-desktop {
@apply hidden;
}
.is-mobile .content-area {
@apply ml-0;
}
/* Smooth transitions for adaptive layouts */
.main-content {
transition: margin-left 0.3s ease;
}
/* Enhanced mobile navigation */
.mobile-nav-header {
backdrop-filter: blur(8px);
}
/* Custom scrollbar for mobile nav */
.mobile-nav-drawer::-webkit-scrollbar {
width: 4px;
}
.mobile-nav-drawer::-webkit-scrollbar-track {
background: transparent;
}
.mobile-nav-drawer::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 2px;
}
/* Responsive typography */
@media (max-width: 640px) {
h1 { @apply text-lg; }
h2 { @apply text-base; }
h3 { @apply text-sm; }
}
@media (min-width: 641px) and (max-width: 1024px) {
h1 { @apply text-xl; }
h2 { @apply text-lg; }
h3 { @apply text-base; }
}
@media (min-width: 1025px) {
h1 { @apply text-2xl; }
h2 { @apply text-xl; }
h3 { @apply text-lg; }
}
</style>