Files
memberApp/src/vueLib/dialog/DialogFrame.vue
Adrian Zürcher a908db4f38 first commit
2025-10-12 14:56:18 +02:00

173 lines
4.1 KiB
Vue

<template>
<q-dialog
ref="dialogRef"
:maximized="minMaxState"
:full-width="minMaxState"
:no-focus="!minMaxState"
:no-refocus="!minMaxState"
:seamless="!minMaxState"
>
<q-card class="layout" :style="cardStyle">
<!-- Draggable Header -->
<div
class="dialog-header row items-center justify-between bg-grey-1"
v-touch-pan.mouse.prevent.stop="handlePan"
>
<div v-if="headerTitle" class="text-left text-bold text-caption q-mx-sm">
{{ headerTitle }}
</div>
<div class="row justify-end q-mx-sm">
<q-btn dense flat :icon="minMaxIcon" size="md" @click="minMax" />
<q-btn dense flat icon="close" size="md" v-close-popup />
</div>
</div>
<q-separator color="black" />
<!-- Content Slot -->
<div class="scrollArea">
<slot />
</div>
<!-- Resize Handle -->
<div v-if="!minMaxState" class="resize-handle" @mousedown.prevent="startResizing" />
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { TouchPanValue } from 'quasar';
const dialogRef = ref();
const open = () => dialogRef.value?.show();
const close = () => dialogRef.value?.hide();
defineExpose({ open, close });
const props = defineProps({
headerTitle: { type: String, default: '' },
width: { type: Number, default: 400 },
height: { type: Number, default: 250 },
});
// Fullscreen toggle
const minMaxIcon = ref('fullscreen');
const minMaxState = ref(false);
function minMax() {
minMaxState.value = !minMaxState.value;
minMaxIcon.value = minMaxState.value ? 'fullscreen_exit' : 'fullscreen';
}
// Position and Size
const position = ref({ x: 0, y: 0 });
const width = ref(props.width || 400);
const height = ref(props.height || 250);
// Watch prop changes and sync local ref
watch(
() => props.width,
(newWidth) => {
if (newWidth !== undefined && newWidth !== width.value) {
width.value = newWidth;
}
},
);
watch(
() => props.height,
(newHeight) => {
if (newHeight !== undefined && newHeight !== height.value) {
height.value = newHeight;
}
},
);
// Dragging (only from header)
const handlePan: TouchPanValue = (details) => {
if (!minMaxState.value && details.delta) {
position.value.x += details.delta.x || 0;
position.value.y += details.delta.y || 0;
}
};
// Resizing
const isResizing = ref(false);
function startResizing(e: MouseEvent) {
isResizing.value = true;
const startX = e.clientX;
const startY = e.clientY;
const startWidth = width.value;
const startHeight = height.value;
function onMouseMove(e: MouseEvent) {
width.value = Math.max(200, startWidth + e.clientX - startX);
height.value = Math.max(200, startHeight + e.clientY - startY);
}
function onMouseUp() {
isResizing.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
// Styles
const cardStyle = computed(() => {
if (minMaxState.value) {
return {};
}
return {
width:
typeof width.value === 'number' || /^\d+$/.test(width.value)
? `${width.value}px`
: width.value,
height:
typeof height.value === 'number' || /^\d+$/.test(height.value)
? `${height.value}px`
: height.value,
transform: `translate(${position.value.x}px, ${position.value.y}px)`,
};
});
</script>
<style scoped>
.layout {
position: relative;
display: flex;
flex-direction: column;
border-radius: 10px;
background-color: white;
}
/* Draggable header */
.dialog-header {
padding: 8px 0;
background: #f5f5f5;
cursor: move;
user-select: none;
}
/* Scrollable content */
.scrollArea {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
/* Resize handle in bottom right */
.resize-handle {
position: absolute;
width: 16px;
height: 16px;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
cursor: nwse-resize;
z-index: 10;
}
</style>