Major refactoring for cleanup, with various bugfixes. Weekday selector in Settings now shows workdays/weekend bars based on current locale (also used to choose default values). Weekday selector in event dialog uses the days set in settings, as expected.
265 lines
7.9 KiB
Vue
265 lines
7.9 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick, useAttrs } from 'vue'
|
|
|
|
// Disable automatic attr inheritance so we can forward class/style specifically to the modal element
|
|
defineOptions({ inheritAttrs: false })
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Boolean, default: false },
|
|
title: { type: String, default: '' },
|
|
draggable: { type: Boolean, default: true },
|
|
autoFocus: { type: Boolean, default: true },
|
|
// Optional external anchor element (e.g., a day cell) to position the dialog below.
|
|
// If not provided, falls back to internal anchorRef span (original behavior).
|
|
anchorEl: { type: Object, default: null },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit'])
|
|
|
|
const modalRef = ref(null)
|
|
const anchorRef = ref(null)
|
|
const isDragging = ref(false)
|
|
const dragOffset = ref({ x: 0, y: 0 })
|
|
const modalPosition = ref({ x: 0, y: 0 })
|
|
const dialogWidth = ref(null)
|
|
const dialogHeight = ref(null)
|
|
const hasMoved = ref(false)
|
|
const margin = 8 // viewport margin in px to keep dialog from touching edges
|
|
|
|
// Collect incoming non-prop attributes (e.g., class / style from usage site)
|
|
const attrs = useAttrs()
|
|
|
|
function clamp(val, min, max) {
|
|
return Math.min(Math.max(val, min), max)
|
|
}
|
|
|
|
function startDrag(event) {
|
|
if (!props.draggable || !modalRef.value) return
|
|
const rect = modalRef.value.getBoundingClientRect()
|
|
// Lock current size so moving doesn't cause reflow / resize
|
|
dialogWidth.value = rect.width
|
|
dialogHeight.value = rect.height
|
|
// Initialize position to current on-screen coordinates BEFORE enabling moved mode
|
|
modalPosition.value = { x: rect.left, y: rect.top }
|
|
isDragging.value = true
|
|
hasMoved.value = true
|
|
dragOffset.value = { x: event.clientX - rect.left, y: event.clientY - rect.top }
|
|
if (event.pointerId !== undefined) {
|
|
try {
|
|
event.target.setPointerCapture(event.pointerId)
|
|
} catch {}
|
|
}
|
|
document.addEventListener('pointermove', handleDrag, { passive: false })
|
|
document.addEventListener('pointerup', stopDrag)
|
|
document.addEventListener('pointercancel', stopDrag)
|
|
event.preventDefault()
|
|
}
|
|
function handleDrag(event) {
|
|
if (!isDragging.value) return
|
|
let x = event.clientX - dragOffset.value.x
|
|
let y = event.clientY - dragOffset.value.y
|
|
const w = dialogWidth.value || modalRef.value?.offsetWidth || 0
|
|
const h = dialogHeight.value || modalRef.value?.offsetHeight || 0
|
|
const vw = window.innerWidth
|
|
const vh = window.innerHeight
|
|
x = clamp(x, margin, Math.max(margin, vw - w - margin))
|
|
y = clamp(y, margin, Math.max(margin, vh - h - margin))
|
|
modalPosition.value = { x, y }
|
|
event.preventDefault()
|
|
}
|
|
function stopDrag() {
|
|
isDragging.value = false
|
|
document.removeEventListener('pointermove', handleDrag)
|
|
document.removeEventListener('pointerup', stopDrag)
|
|
document.removeEventListener('pointercancel', stopDrag)
|
|
}
|
|
|
|
const modalStyle = computed(() => {
|
|
// Always position relative to calculated modalPosition once opened
|
|
if (modalRef.value && props.modelValue) {
|
|
const style = {
|
|
transform: 'none',
|
|
insetInlineStart: modalPosition.value.x + 'px',
|
|
top: modalPosition.value.y + 'px',
|
|
bottom: 'auto',
|
|
insetInlineEnd: 'auto',
|
|
}
|
|
if (hasMoved.value) {
|
|
style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined
|
|
style.height = dialogHeight.value ? dialogHeight.value + 'px' : undefined
|
|
}
|
|
return style
|
|
}
|
|
return {}
|
|
})
|
|
|
|
// Merge external class / style with internal ones so parent usage like
|
|
// <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root.
|
|
const modalAttrs = computed(() => {
|
|
const { class: extClass, style: extStyle, ...rest } = attrs
|
|
return {
|
|
...rest,
|
|
class: ['ec-modal', extClass].filter(Boolean),
|
|
style: [modalStyle.value, extStyle].filter(Boolean), // external style overrides internal
|
|
}
|
|
})
|
|
|
|
function close() {
|
|
emit('update:modelValue', false)
|
|
emit('closed')
|
|
}
|
|
|
|
function handleKeydown(e) {
|
|
if (e.key === 'Escape' && props.modelValue) close()
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
|
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
|
|
|
function positionNearAnchor() {
|
|
const anchor = props.anchorEl || anchorRef.value
|
|
if (!anchor) return
|
|
const rect = anchor.getBoundingClientRect()
|
|
const offsetY = 8 // vertical gap below the anchor
|
|
const w = modalRef.value?.offsetWidth || dialogWidth.value || 320
|
|
const h = modalRef.value?.offsetHeight || dialogHeight.value || 200
|
|
const vw = window.innerWidth
|
|
const vh = window.innerHeight
|
|
let x = rect.left
|
|
let y = rect.bottom + offsetY
|
|
// If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align
|
|
x = clamp(x, margin, Math.max(margin, vw - w - margin))
|
|
y = clamp(y, margin, Math.max(margin, vh - h - margin))
|
|
modalPosition.value = { x, y }
|
|
}
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
async (v) => {
|
|
if (v) {
|
|
emit('opened')
|
|
await nextTick()
|
|
// Reset movement state each time opened
|
|
hasMoved.value = false
|
|
dialogWidth.value = null
|
|
dialogHeight.value = null
|
|
positionNearAnchor()
|
|
if (props.autoFocus) {
|
|
const el = modalRef.value?.querySelector('[autofocus]')
|
|
if (el) el.focus()
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Reposition if anchorEl changes while open and user hasn't dragged dialog yet
|
|
watch(
|
|
() => props.anchorEl,
|
|
() => {
|
|
if (props.modelValue && !hasMoved.value) {
|
|
nextTick(() => positionNearAnchor())
|
|
}
|
|
},
|
|
)
|
|
|
|
function handleResize() {
|
|
if (!props.modelValue) return
|
|
// Re-clamp current position, and if not moved recalc near anchor
|
|
if (!hasMoved.value) positionNearAnchor()
|
|
else if (modalRef.value) {
|
|
const w = modalRef.value.offsetWidth
|
|
const h = modalRef.value.offsetHeight
|
|
const vw = window.innerWidth
|
|
const vh = window.innerHeight
|
|
modalPosition.value = {
|
|
x: clamp(modalPosition.value.x, margin, Math.max(margin, vw - w - margin)),
|
|
y: clamp(modalPosition.value.y, margin, Math.max(margin, vh - h - margin)),
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', handleResize)
|
|
})
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleResize)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<span ref="anchorRef" class="ec-modal-anchor" aria-hidden="true"></span>
|
|
<div v-if="modelValue" ref="modalRef" v-bind="modalAttrs">
|
|
<form class="ec-form" @submit.prevent="emit('submit')">
|
|
<header class="ec-header" @pointerdown="startDrag">
|
|
<h2 class="ec-title">
|
|
<slot name="title">{{ title }}</slot>
|
|
</h2>
|
|
<div class="ec-header-extra"><slot name="header-extra" /></div>
|
|
</header>
|
|
<div class="ec-body">
|
|
<slot />
|
|
</div>
|
|
<footer v-if="$slots.footer" class="ec-footer">
|
|
<slot name="footer" />
|
|
</footer>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ec-modal {
|
|
position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */
|
|
background: color-mix(in srgb, var(--panel) 85%, transparent);
|
|
backdrop-filter: blur(0.625em);
|
|
-webkit-backdrop-filter: blur(0.625em);
|
|
color: var(--ink);
|
|
border-radius: 0.6em;
|
|
min-height: 23em;
|
|
min-width: 26em;
|
|
max-width: min(34em, 90vw);
|
|
box-shadow: 0 0.6em 1.8em rgba(0, 0, 0, 0.35);
|
|
border: 0.0625em solid color-mix(in srgb, var(--muted) 40%, transparent);
|
|
z-index: 1000;
|
|
overflow: hidden;
|
|
}
|
|
.ec-modal-anchor {
|
|
display: inline-block;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
.ec-form {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr auto;
|
|
min-height: 23em;
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
.ec-header {
|
|
cursor: move;
|
|
user-select: none;
|
|
padding: 0.75em 1em 0.5em 1em;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1em;
|
|
}
|
|
.ec-title {
|
|
margin: 0;
|
|
font-size: 1.1em;
|
|
}
|
|
.ec-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1em;
|
|
padding: 0 1em 0.5em 1em;
|
|
overflow: auto;
|
|
}
|
|
.ec-footer {
|
|
padding: 0.5em 1em 1em 1em;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 1em;
|
|
flex-wrap: wrap;
|
|
}
|
|
</style>
|