1
0
Files
TaskBoard/front_vue/src/components/ui/SlidePanel.vue
2026-01-21 12:46:43 +07:00

384 lines
8.3 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>
<Transition name="panel">
<div
v-if="show"
class="panel-overlay"
@mousedown.self="onOverlayMouseDown"
@mouseup.self="onOverlayMouseUp"
>
<div
class="panel"
:class="{ mobile: isMobile }"
:style="{ width: panelWidth + 'px' }"
ref="panelRef"
@mousedown="overlayMouseDownTarget = false"
>
<!-- Ручка для изменения ширины -->
<div
class="resize-handle"
@mousedown="startResize"
></div>
<div class="panel-header">
<div class="header-content">
<slot name="header"></slot>
</div>
<button class="btn-close" @click="handleClose">
<i data-lucide="x"></i>
</button>
</div>
<div class="panel-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="panel-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useMobile } from '../../composables/useMobile'
import { useLucideIcons } from '../../composables/useLucideIcons'
const { isMobile } = useMobile()
useLucideIcons()
const props = defineProps({
show: {
type: Boolean,
default: false
},
width: {
type: Number,
default: 600
},
minWidth: {
type: Number,
default: 500
},
maxWidth: {
type: Number,
default: 1000
},
resizable: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['close', 'update:show'])
const STORAGE_KEY = 'taskboard_panel_width'
const panelRef = ref(null)
const panelWidth = ref(getSavedWidth())
const isResizing = ref(false)
const overlayMouseDownTarget = ref(false)
// Получить сохранённую ширину из localStorage
function getSavedWidth() {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const width = parseInt(saved, 10)
if (!isNaN(width)) return width
}
return props.width
}
// Сохранить ширину в localStorage
function saveWidth(width) {
localStorage.setItem(STORAGE_KEY, String(width))
}
// Resize логика
const startResize = (e) => {
if (!props.resizable) return
isResizing.value = true
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}
const onResize = (e) => {
if (!isResizing.value) return
const newWidth = window.innerWidth - e.clientX
panelWidth.value = Math.min(props.maxWidth, Math.max(props.minWidth, newWidth))
}
const stopResize = () => {
// Сохраняем ширину в localStorage
saveWidth(panelWidth.value)
setTimeout(() => {
isResizing.value = false
}, 100)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// Закрытие по клику на overlay
const onOverlayMouseDown = () => {
overlayMouseDownTarget.value = true
}
const onOverlayMouseUp = () => {
if (overlayMouseDownTarget.value && !isResizing.value) {
handleClose()
}
overlayMouseDownTarget.value = false
}
const handleClose = () => {
emit('close')
emit('update:show', false)
}
// Блокировка скролла body при открытии панели
const lockBodyScroll = () => {
document.body.classList.add('panel-open')
}
const unlockBodyScroll = () => {
document.body.classList.remove('panel-open')
}
// Принудительный reflow для iOS PWA - исправляет баг с safe-area при первом рендере
const forceReflow = () => {
if (panelRef.value) {
// Читаем offsetHeight чтобы вызвать reflow
void panelRef.value.offsetHeight
}
}
// Восстановление ширины при открытии (из localStorage или дефолтная)
watch(() => props.show, (newVal) => {
if (newVal) {
panelWidth.value = getSavedWidth()
lockBodyScroll()
// Два requestAnimationFrame для гарантированного reflow после рендера
requestAnimationFrame(() => {
requestAnimationFrame(forceReflow)
})
} else {
unlockBodyScroll()
}
})
onUnmounted(() => {
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
unlockBodyScroll()
})
</script>
<style scoped>
.panel-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
/* Блокируем горизонтальные свайпы на мобильных */
touch-action: pan-y pinch-zoom;
overscroll-behavior: contain;
}
.panel {
position: fixed;
top: 0;
right: 0;
max-width: 100%;
height: 100vh;
height: 100dvh; /* Динамическая высота для iOS */
background: #18181b;
display: flex;
flex-direction: column;
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.4);
}
.resize-handle {
position: absolute;
left: 0;
top: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
background: transparent;
transition: background 0.15s;
z-index: 10;
}
.resize-handle:hover,
.resize-handle:active {
background: var(--accent);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.header-content {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
}
.header-content :deep(h2) {
font-size: 20px;
font-weight: 500;
margin: 0;
}
.header-content :deep(.header-title-block) {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-content :deep(.header-date) {
font-size: 12px;
color: var(--text-muted);
}
.header-content :deep(.header-tabs) {
margin-left: auto;
}
.btn-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-close i {
width: 20px;
height: 20px;
}
.btn-close:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.panel-body {
flex: 1;
padding: 32px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel-body::-webkit-scrollbar {
width: 6px;
}
.panel-body::-webkit-scrollbar-track {
background: transparent;
}
.panel-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.panel-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.panel-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* Transition — выезд справа для десктопа */
.panel-enter-active,
.panel-leave-active {
transition: opacity 0.25s ease;
}
.panel-enter-active .panel,
.panel-leave-active .panel {
transition: transform 0.25s ease;
}
.panel-enter-from,
.panel-leave-to {
opacity: 0;
}
.panel-enter-from .panel,
.panel-leave-to .panel {
transform: translateX(100%);
}
/* На мобильных убираем transform из анимации - он ломает layout в iOS PWA */
.panel-enter-from .panel.mobile,
.panel-leave-to .panel.mobile {
transform: none;
}
/* ========== MOBILE: Fullscreen ========== */
/* height: 100% вместо 100dvh - потому что wrapper уже учитывает safe-area */
.panel.mobile {
width: 100% !important;
max-width: 100%;
height: 100%; /* Занимаем всю высоту wrapper, не viewport */
border-radius: 0;
touch-action: pan-y pinch-zoom;
overscroll-behavior: contain;
/* Сплошной цвет вместо градиента */
background: var(--bg-body);
}
.panel.mobile .resize-handle {
display: none;
}
.panel.mobile .panel-header {
padding: 16px;
}
.panel.mobile .panel-body {
padding: 16px;
min-height: 0;
gap: 20px;
overflow-y: auto;
}
.panel.mobile .panel-footer {
padding: 16px;
/* Safe area для iPhone home indicator */
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
.panel.mobile .header-content :deep(h2) {
font-size: 18px;
}
</style>