Header refactor, styling, collapsible header controls.

This commit is contained in:
Leo Vasanko 2025-08-25 08:31:02 -06:00
parent 8e91d360be
commit 100b420003
7 changed files with 298 additions and 408 deletions

View File

@ -1,73 +0,0 @@
<template>
<div class="wrap">
<!-- AppHeader component reference removed (file missing); add inline header with Settings button -->
<div class="inline-header">
<h1 class="app-title">Calendar</h1>
<div class="header-actions">
<button type="button" class="settings-btn" @click="openSettings">Settings</button>
</div>
</div>
<div class="calendar-container" ref="containerEl">
<CalendarGrid />
<Jogwheel />
</div>
<EventDialog />
<SettingsDialog ref="settingsDialog" />
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import CalendarGrid from './CalendarGrid.vue'
import Jogwheel from './Jogwheel.vue'
import EventDialog from './EventDialog.vue'
import SettingsDialog from './SettingsDialog.vue'
import { useCalendarStore } from '@/stores/CalendarStore'
const calendarStore = useCalendarStore()
const containerEl = ref(null)
const settingsDialog = ref(null)
function openSettings() {
settingsDialog.value?.open()
}
let intervalId
onMounted(() => {
calendarStore.setToday()
intervalId = setInterval(() => {
calendarStore.setToday()
}, 1000)
})
onBeforeUnmount(() => {
clearInterval(intervalId)
})
</script>
<style scoped>
.inline-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
}
.app-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.settings-btn {
border: 1px solid var(--muted);
background: var(--panel-alt, transparent);
color: var(--ink);
padding: 0.4rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.75rem;
}
.settings-btn:hover {
background: var(--muted);
}
</style>

View File

@ -81,37 +81,34 @@ const props = defineProps({
.cell.selected h1 {
color: var(--strong);
}
.lunar-phase {
position: absolute;
top: 0.1em;
right: 0.1em;
top: 0.5em;
right: 0.2em;
font-size: 0.8em;
opacity: 0.7;
}
.cell.holiday {
/* Remove solid background & border color overrides; use gradient overlay instead */
position: relative;
background-image: linear-gradient(
135deg,
var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%,
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
);
}
.cell.holiday::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
@media (prefers-color-scheme: dark) {
.cell.holiday {
background-image: linear-gradient(
135deg,
var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%,
var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70%
);
pointer-events: none;
mix-blend-mode: normal; /* can switch to 'overlay' or 'screen' if thematic */
}
}
.cell.holiday h1 {
/* Slight emphasis without forcing a specific hue */
color: var(--holiday);
text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4);
}
.holiday-info {
position: absolute;
bottom: 0.1em;

View File

@ -1,176 +0,0 @@
<template>
<div class="calendar-header">
<div class="year-label" @wheel.prevent="handleWheel">{{ calendarStore.viewYear }}</div>
<div v-for="day in weekdayNames" :key="day" class="dow" :class="{ weekend: isWeekend(day) }">
{{ day }}
</div>
<div class="overlay-header-spacer"></div>
</div>
<div class="calendar-viewport" ref="viewportEl" @scroll="handleScroll">
<div class="calendar-content" :style="{ height: `${totalVirtualWeeks * rowHeight}px` }">
<WeekRow
v.for="week in visibleWeeks"
:key="week.virtualWeek"
:week="week"
:style="{ top: `${(week.virtualWeek - minVirtualWeek) * rowHeight}px` }"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import {
getLocalizedWeekdayNames,
getLocaleWeekendDays,
getLocaleFirstDay,
getISOWeek,
getISOWeekYear,
fromLocalString,
toLocalString,
mondayIndex,
DEFAULT_TZ,
MIN_YEAR,
MAX_YEAR,
} from '@/utils/date'
import { addDays } from 'date-fns'
import WeekRow from './WeekRow.vue'
const calendarStore = useCalendarStore()
const viewportEl = ref(null)
const rowHeight = ref(64) // Default value, will be computed
const totalVirtualWeeks = ref(0)
const minVirtualWeek = ref(0)
const visibleWeeks = ref([])
const config = {
weekend: getLocaleWeekendDays(),
}
// Anchor Monday (or locale first day) reference date
const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay())
const WEEK_MS = 7 * 86400000
const weekdayNames = getLocalizedWeekdayNames()
const isWeekend = (day) => {
const dayIndex = weekdayNames.indexOf(day)
return config.weekend[(dayIndex + 1) % 7]
}
const getWeekIndex = (date) => {
const monday = addDays(date, -mondayIndex(date))
return Math.floor((monday.getTime() - baseDate.getTime()) / WEEK_MS)
}
const getMondayForVirtualWeek = (virtualWeek) => addDays(baseDate, virtualWeek * 7)
const computeRowHeight = () => {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = 'var(--cell-h)'
document.body.appendChild(el)
const h = el.getBoundingClientRect().height || 64
el.remove()
return Math.round(h)
}
const updateVisibleWeeks = () => {
if (!viewportEl.value) return
const scrollTop = viewportEl.value.scrollTop
const viewportH = viewportEl.value.clientHeight
const topDisplayIndex = Math.floor(scrollTop / rowHeight.value)
const topVW = topDisplayIndex + minVirtualWeek.value
const monday = getMondayForVirtualWeek(topVW)
const year = getISOWeekYear(monday)
if (calendarStore.viewYear !== year) {
calendarStore.setViewYear(year)
}
const buffer = 10
const startIdx = Math.floor((scrollTop - buffer * rowHeight.value) / rowHeight.value)
const endIdx = Math.ceil((scrollTop + viewportH + buffer * rowHeight.value) / rowHeight.value)
const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value)
const endVW = Math.min(
totalVirtualWeeks.value + minVirtualWeek.value - 1,
endIdx + minVirtualWeek.value,
)
const newVisibleWeeks = []
for (let vw = startVW; vw <= endVW; vw++) {
newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) })
}
visibleWeeks.value = newVisibleWeeks
}
const handleScroll = () => {
requestAnimationFrame(updateVisibleWeeks)
}
const handleWheel = (e) => {
const currentYear = calendarStore.viewYear
const delta = Math.round(e.deltaY * (1 / 3))
if (!delta) return
const newYear = Math.max(MIN_YEAR, Math.min(MAX_YEAR, currentYear + delta))
if (newYear === currentYear) return
const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value)
const currentWeekIndex = topDisplayIndex + minVirtualWeek.value
navigateToYear(newYear, currentWeekIndex)
}
const navigateToYear = (targetYear, weekIndex) => {
const monday = getMondayForVirtualWeek(weekIndex)
const week = getISOWeek(monday)
const jan4 = new Date(targetYear, 0, 4)
const jan4Monday = addDays(jan4, -mondayIndex(jan4))
const targetMonday = addDays(jan4Monday, (week - 1) * 7)
scrollToTarget(targetMonday)
}
const scrollToTarget = (target) => {
let targetWeekIndex
if (target instanceof Date) {
targetWeekIndex = getWeekIndex(target)
} else {
targetWeekIndex = target
}
const targetScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
viewportEl.value.scrollTop = targetScrollTop
updateVisibleWeeks()
}
const goToTodayHandler = () => {
const today = new Date()
const top = addDays(today, -21)
scrollToTarget(top)
}
onMounted(() => {
rowHeight.value = computeRowHeight()
const minYearDate = new Date(MIN_YEAR, 0, 1)
const maxYearLastDay = new Date(MAX_YEAR, 11, 31)
const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
minVirtualWeek.value = getWeekIndex(minYearDate)
const maxVirtualWeek = getWeekIndex(lastWeekMonday)
totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1
const initialDate = fromLocalString(calendarStore.today, DEFAULT_TZ)
scrollToTarget(initialDate)
document.addEventListener('goToToday', goToTodayHandler)
})
onBeforeUnmount(() => {
document.removeEventListener('goToToday', goToTodayHandler)
})
</script>

View File

@ -144,7 +144,6 @@ const weekdayNames = computed(() => {
.dow {
text-transform: uppercase;
text-align: center;
padding: 0.5rem;
font-weight: 500;
}
.dow.weekend {

View File

@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import CalendarHeader from '@/components/CalendarHeader.vue'
import CalendarWeek from '@/components/CalendarWeek.vue'
import HeaderControls from '@/components/HeaderControls.vue'
import Jogwheel from '@/components/Jogwheel.vue'
import SettingsDialog from '@/components/SettingsDialog.vue'
import {
@ -13,7 +14,6 @@ import {
daysInclusive,
addDaysStr,
formatDateRange,
formatTodayString,
getOccurrenceIndex,
getVirtualOccurrenceEndDate,
getISOWeek,
@ -113,11 +113,6 @@ const selectedDateRange = computed(() => {
)
})
const todayString = computed(() => {
const d = new Date(calendarStore.now)
return formatTodayString(d)
})
// PERFORMANCE: Maintain a manual cache of computed weeks instead of relying on
// deep reactive tracking of every event & day object. We rebuild lazily when
// (a) scrolling changes the needed range or (b) eventsMutation counter bumps.
@ -704,44 +699,12 @@ window.addEventListener('resize', () => {
<template>
<!-- hidden probe for measuring var(--cell-h) -->
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
<!-- existing template root starts below -->
<div class="wrap">
<header>
<h1>Calendar</h1>
<div class="header-controls">
<div class="today-date" @click="goToToday">{{ todayString }}</div>
<!-- Reference historyTick to ensure reactivity of canUndo/canRedo -->
<button
type="button"
class="hist-btn"
:disabled="!calendarStore.historyCanUndo"
@click="calendarStore.$history?.undo()"
title="Undo (Ctrl+Z)"
aria-label="Undo"
>
</button>
<button
type="button"
class="hist-btn"
:disabled="!calendarStore.historyCanRedo"
@click="calendarStore.$history?.redo()"
title="Redo (Ctrl+Shift+Z)"
aria-label="Redo"
>
</button>
<button
type="button"
class="settings-btn"
@click="openSettings"
aria-label="Open settings"
title="Settings"
>
</button>
</div>
</header>
<HeaderControls
@go-to-today="goToToday"
@open-settings="openSettings"
/>
<CalendarHeader
:scroll-top="scrollTop"
:row-height="rowHeight"
@ -811,81 +774,6 @@ header h1 {
font-size: 1.6rem;
font-weight: 600;
}
.header-controls {
display: flex;
gap: 0.6rem;
align-items: center;
margin-left: auto;
}
.settings-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0;
margin-right: 0.6rem;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
}
.hist-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
width: 1.9rem;
height: 1.9rem;
}
.hist-btn:disabled {
opacity: 0.35;
cursor: default;
}
.hist-btn:not(:disabled):hover,
.hist-btn:not(:disabled):focus-visible {
color: var(--strong);
}
.hist-btn:active:not(:disabled) {
transform: scale(0.88);
}
.settings-btn:hover {
color: var(--strong);
}
.settings-btn:focus-visible {
/* Keep visual accessibility without background */
outline: 2px solid var(--selected);
outline-offset: 2px;
}
.settings-btn:active {
transform: scale(0.88);
}
.today-date {
cursor: pointer;
padding: 0.5rem;
background: var(--today-btn-bg);
color: var(--today-btn-text);
border-radius: 4px;
white-space: pre-line;
text-align: center;
font-size: 0.9rem;
}
.today-date:hover {
background: var(--today-btn-hover-bg);
}
.calendar-container {
flex: 1;
@ -940,5 +828,10 @@ header h1 {
transform: none;
}
.row-height-probe { position: absolute; visibility: hidden; height: var(--cell-h); pointer-events: none; }
.row-height-probe {
position: absolute;
visibility: hidden;
height: var(--cell-h);
pointer-events: none;
}
</style>

View File

@ -0,0 +1,225 @@
<template>
<!-- Header controls with transition -->
<Transition name="header-controls" appear>
<div v-if="isVisible" class="header-controls">
<div class="today-date" @click="goToToday">{{ todayString }}</div>
<!-- Reference historyTick to ensure reactivity of canUndo/canRedo -->
<button
type="button"
class="hist-btn"
:disabled="!calendarStore.historyCanUndo"
@click="calendarStore.$history?.undo()"
title="Undo (Ctrl+Z)"
aria-label="Undo"
>
</button>
<button
type="button"
class="hist-btn"
:disabled="!calendarStore.historyCanRedo"
@click="calendarStore.$history?.redo()"
title="Redo (Ctrl+Shift+Z)"
aria-label="Redo"
>
</button>
<button
type="button"
class="settings-btn"
@click="$emit('open-settings')"
aria-label="Open settings"
title="Settings"
>
</button>
</div>
</Transition>
<button
type="button"
class="toggle-btn"
@click="toggleVisibility"
:aria-label="isVisible ? 'Hide controls' : 'Show controls'"
:title="isVisible ? 'Hide controls' : 'Show controls'"
>
</button>
</template>
<script setup>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import { formatTodayString } from '@/utils/date'
import { addDays } from 'date-fns'
const calendarStore = useCalendarStore()
const todayString = computed(() => {
const d = new Date(calendarStore.now)
return formatTodayString(d)
})
const emit = defineEmits(['open-settings', 'go-to-today'])
function goToToday() {
// Emit the event so the parent can handle the viewport scrolling logic
// since this component doesn't have access to viewport refs
emit('go-to-today')
}
// Screen size detection and visibility toggle
const isVisible = ref(false)
function checkScreenSize() {
const isSmallScreen = window.innerHeight < 600
// Default to open on large screens, closed on small screens
isVisible.value = !isSmallScreen
}
function toggleVisibility() {
isVisible.value = !isVisible.value
}
onMounted(() => {
checkScreenSize()
window.addEventListener('resize', checkScreenSize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkScreenSize)
})
</script>
<style scoped>
.header-controls {
display: flex;
align-items: center;
margin-left: auto;
margin-right: 2.5em;
gap: 0.6rem;
min-height: 0;
}
.header-controls {
display: flex;
gap: 0.6rem;
align-items: center;
}
.toggle-btn {
position: fixed;
top: 0;
right: 0;
background: none;
border: none;
color: var(--muted);
padding: 0;
margin: 0.5em;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
width: 1em;
height: 1em;
transition: all 0.2s ease;
}
.toggle-btn:hover {
color: var(--strong);
transform: scale(1.1);
}
.toggle-btn:active {
transform: scale(0.9);
}
/* Transition animations */
.header-controls-enter-active,
.header-controls-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.header-controls-enter-from,
.header-controls-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-20px);
}
.header-controls-enter-to,
.header-controls-leave-from {
opacity: 1;
max-height: 100px;
transform: translateY(0);
}
.settings-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0;
margin-right: 0.6rem;
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
}
.hist-btn {
background: transparent;
border: none;
color: var(--muted);
padding: 0;
margin: 0;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
outline: none;
width: 1.9rem;
height: 1.9rem;
}
.hist-btn:disabled {
opacity: 0.35;
cursor: default;
}
.hist-btn:not(:disabled):hover,
.hist-btn:not(:disabled):focus-visible {
color: var(--strong);
}
.hist-btn:active:not(:disabled) {
transform: scale(0.88);
}
.settings-btn:hover {
color: var(--strong);
}
.today-date {
cursor: pointer;
padding: 0.5rem;
background: var(--today-btn-bg);
color: var(--today-btn-text);
border-radius: 4px;
white-space: pre-line;
text-align: center;
font-size: 0.9rem;
}
.today-date:hover {
background: var(--today-btn-hover-bg);
}
</style>

View File

@ -1,6 +1,10 @@
<template>
<div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll">
<div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div>
<div
class="jogwheel-content"
ref="jogwheelContent"
:style="{ height: jogwheelHeight + 'px' }"
></div>
</div>
</template>
@ -11,7 +15,7 @@ const props = defineProps({
totalVirtualWeeks: { type: Number, required: true },
rowHeight: { type: Number, required: true },
viewportHeight: { type: Number, required: true },
scrollTop: { type: Number, required: true }
scrollTop: { type: Number, required: true },
})
const emit = defineEmits(['scroll-to'])
@ -42,10 +46,16 @@ function onDragMouseDown(e) {
mainStartScroll = props.scrollTop
accumDelta = 0
// Precompute scale between jogwheel scrollable range and main scrollable range
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
let jogScrollable = 0
if (jogwheelViewport.value && jogwheelContent.value) {
jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
}
dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1
if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1
@ -108,8 +118,14 @@ const syncFromJogwheel = () => {
syncLock.value = 'main'
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
const jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
if (jogScrollable > 0) {
const ratio = jogwheelViewport.value.scrollTop / jogScrollable
@ -129,8 +145,14 @@ const syncFromMain = (mainScrollTop) => {
syncLock.value = 'jogwheel'
const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight)
const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight)
const mainScrollable = Math.max(
0,
props.totalVirtualWeeks * props.rowHeight - props.viewportHeight,
)
const jogScrollable = Math.max(
0,
jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight,
)
if (mainScrollable > 0) {
const ratio = mainScrollTop / mainScrollable
@ -143,12 +165,15 @@ const syncFromMain = (mainScrollTop) => {
}
// Watch for main calendar scroll changes
watch(() => props.scrollTop, (newScrollTop) => {
watch(
() => props.scrollTop,
(newScrollTop) => {
syncFromMain(newScrollTop)
})
},
)
defineExpose({
syncFromMain
syncFromMain,
})
</script>