Settings dialog inside the settings button.

This commit is contained in:
Leo Vasanko 2025-08-25 10:59:08 -06:00
parent a72bc4dc75
commit 7ed6fd9b29
4 changed files with 89 additions and 31 deletions

View File

@ -11,12 +11,18 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit']) const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit'])
const modalRef = ref(null) const modalRef = ref(null)
const anchorRef = ref(null)
const isDragging = ref(false) const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 }) const dragOffset = ref({ x: 0, y: 0 })
const modalPosition = ref({ x: 0, y: 0 }) const modalPosition = ref({ x: 0, y: 0 })
const dialogWidth = ref(null) const dialogWidth = ref(null)
const dialogHeight = ref(null) const dialogHeight = ref(null)
const hasMoved = ref(false) const hasMoved = ref(false)
const margin = 8 // viewport margin in px to keep dialog from touching edges
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max)
}
function startDrag(event) { function startDrag(event) {
if (!props.draggable || !modalRef.value) return if (!props.draggable || !modalRef.value) return
@ -41,10 +47,15 @@ function startDrag(event) {
} }
function handleDrag(event) { function handleDrag(event) {
if (!isDragging.value) return if (!isDragging.value) return
modalPosition.value = { let x = event.clientX - dragOffset.value.x
x: event.clientX - dragOffset.value.x, let y = event.clientY - dragOffset.value.y
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() event.preventDefault()
} }
function stopDrag() { function stopDrag() {
@ -55,16 +66,20 @@ function stopDrag() {
} }
const modalStyle = computed(() => { const modalStyle = computed(() => {
if (hasMoved.value) { // Always position relative to calculated modalPosition once opened
return { if (modalRef.value && props.modelValue) {
const style = {
transform: 'none', transform: 'none',
left: `${modalPosition.value.x}px`, left: modalPosition.value.x + 'px',
top: `${modalPosition.value.y}px`, top: modalPosition.value.y + 'px',
bottom: 'auto', bottom: 'auto',
right: 'auto', right: 'auto',
width: dialogWidth.value ? dialogWidth.value + 'px' : undefined,
height: dialogHeight.value ? dialogHeight.value + 'px' : undefined,
} }
if (hasMoved.value) {
style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined
style.height = dialogHeight.value ? dialogHeight.value + 'px' : undefined
}
return style
} }
return {} return {}
}) })
@ -81,12 +96,34 @@ function handleKeydown(e) {
onMounted(() => document.addEventListener('keydown', handleKeydown)) onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown)) onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
function positionNearAnchor() {
if (!anchorRef.value) return
const rect = anchorRef.value.getBoundingClientRect()
// Place dialog below anchor with small vertical offset
const offsetY = 8
// Need dialog dimensions to clamp correctly; measure current or fallback estimates
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
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( watch(
() => props.modelValue, () => props.modelValue,
async (v) => { async (v) => {
if (v) { if (v) {
emit('opened') emit('opened')
await nextTick() await nextTick()
// Reset movement state each time opened
hasMoved.value = false
dialogWidth.value = null
dialogHeight.value = null
positionNearAnchor()
if (props.autoFocus) { if (props.autoFocus) {
const el = modalRef.value?.querySelector('[autofocus]') const el = modalRef.value?.querySelector('[autofocus]')
if (el) el.focus() if (el) el.focus()
@ -94,9 +131,33 @@ watch(
} }
}, },
) )
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> </script>
<template> <template>
<span ref="anchorRef" class="ec-modal-anchor" aria-hidden="true"></span>
<div v-if="modelValue" class="ec-modal" ref="modalRef" :style="modalStyle"> <div v-if="modelValue" class="ec-modal" ref="modalRef" :style="modalStyle">
<form class="ec-form" @submit.prevent="emit('submit')"> <form class="ec-form" @submit.prevent="emit('submit')">
<header class="ec-header" @pointerdown="startDrag"> <header class="ec-header" @pointerdown="startDrag">
@ -117,9 +178,7 @@ watch(
<style scoped> <style scoped>
.ec-modal { .ec-modal {
position: fixed; position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */
bottom: 3em;
right: 2em;
background: color-mix(in srgb, var(--panel) 85%, transparent); background: color-mix(in srgb, var(--panel) 85%, transparent);
backdrop-filter: blur(0.625em); backdrop-filter: blur(0.625em);
-webkit-backdrop-filter: blur(0.625em); -webkit-backdrop-filter: blur(0.625em);
@ -133,6 +192,11 @@ watch(
z-index: 1000; z-index: 1000;
overflow: hidden; overflow: hidden;
} }
.ec-modal-anchor {
display: inline-block;
width: 0;
height: 0;
}
.ec-form { .ec-form {
display: grid; display: grid;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;

View File

@ -5,7 +5,6 @@ import CalendarHeader from '@/components/CalendarHeader.vue'
import CalendarWeek from '@/components/CalendarWeek.vue' import CalendarWeek from '@/components/CalendarWeek.vue'
import HeaderControls from '@/components/HeaderControls.vue' import HeaderControls from '@/components/HeaderControls.vue'
import Jogwheel from '@/components/Jogwheel.vue' import Jogwheel from '@/components/Jogwheel.vue'
import SettingsDialog from '@/components/SettingsDialog.vue'
import { import {
getLocalizedMonthName, getLocalizedMonthName,
monthAbbr, monthAbbr,
@ -26,7 +25,6 @@ import { getHolidayForDate } from '@/utils/holidays'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
const viewport = ref(null) const viewport = ref(null)
const settingsDialog = ref(null)
const emit = defineEmits(['create-event', 'edit-event']) const emit = defineEmits(['create-event', 'edit-event'])
@ -645,9 +643,6 @@ const handleHeaderYearChange = ({ scrollTop: st }) => {
viewport.value && (viewport.value.scrollTop = clamped) viewport.value && (viewport.value.scrollTop = clamped)
} }
function openSettings() {
settingsDialog.value?.open()
}
// Heuristic: rotate month label (180deg) only for predominantly Latin text. // Heuristic: rotate month label (180deg) only for predominantly Latin text.
// We explicitly avoid locale detection; rely solely on characters present. // We explicitly avoid locale detection; rely solely on characters present.
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears. // Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
@ -687,8 +682,7 @@ window.addEventListener('resize', () => {
<template> <template>
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
<div class="wrap"> <div class="wrap">
<HeaderControls @go-to-today="goToToday" @open-settings="openSettings" /> <HeaderControls @go-to-today="goToToday" />
<CalendarHeader <CalendarHeader
:scroll-top="scrollTop" :scroll-top="scrollTop"
:row-height="rowHeight" :row-height="rowHeight"
@ -720,7 +714,6 @@ window.addEventListener('resize', () => {
@scroll-to="handleJogwheelScrollTo" @scroll-to="handleJogwheelScrollTo"
/> />
</div> </div>
<SettingsDialog ref="settingsDialog" />
</div> </div>
</template> </template>

View File

@ -25,12 +25,14 @@
<button <button
type="button" type="button"
class="settings-btn" class="settings-btn"
@click="$emit('open-settings')" @click="openSettings"
aria-label="Open settings" aria-label="Open settings"
title="Settings" title="Settings"
> >
</button> </button>
<!-- Settings dialog now lives here -->
<SettingsDialog ref="settingsDialog" />
</div> </div>
</Transition> </Transition>
<button <button
@ -48,6 +50,7 @@
import { computed, ref, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { formatTodayString } from '@/utils/date' import { formatTodayString } from '@/utils/date'
import SettingsDialog from '@/components/SettingsDialog.vue'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
@ -56,7 +59,7 @@ const todayString = computed(() => {
return formatTodayString(d) return formatTodayString(d)
}) })
const emit = defineEmits(['open-settings', 'go-to-today']) const emit = defineEmits(['go-to-today'])
function goToToday() { function goToToday() {
// Emit the event so the parent can handle the viewport scrolling logic // Emit the event so the parent can handle the viewport scrolling logic
@ -77,6 +80,12 @@ function toggleVisibility() {
isVisible.value = !isVisible.value isVisible.value = !isVisible.value
} }
// Settings dialog integration
const settingsDialog = ref(null)
function openSettings() {
settingsDialog.value?.open()
}
onMounted(() => { onMounted(() => {
checkScreenSize() checkScreenSize()
window.addEventListener('resize', checkScreenSize) window.addEventListener('resize', checkScreenSize)

View File

@ -306,12 +306,4 @@ select {
.ec-btn.delete-btn:hover { .ec-btn.delete-btn:hover {
background: hsl(0, 70%, 45%); background: hsl(0, 70%, 45%);
} }
/* Global override to ensure settings dialog appears near top by default */
.ec-modal.settings-modal {
top: 4.5rem !important;
right: 2rem !important;
bottom: auto !important;
left: auto !important;
transform: none !important;
}
</style> </style>