feat(calendar): month/year picker popover on title click
Click the month name in the FullCalendar toolbar to open a popover with prev/next year arrows and a 4×3 month grid. Clicking a month jumps the calendar to that month via gotoDate(). Current month highlighted. Picker closes on outside click. Title gains hover highlight + pointer cursor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,27 @@ async function loadUpcoming() {
|
|||||||
|
|
||||||
onMounted(loadUpcoming);
|
onMounted(loadUpcoming);
|
||||||
|
|
||||||
|
// ── Month/year picker ──────────────────────────────────────────────────────
|
||||||
|
const currentViewYear = ref(new Date().getFullYear());
|
||||||
|
const currentViewMonth = ref(new Date().getMonth());
|
||||||
|
const pickerOpen = ref(false);
|
||||||
|
const pickerYear = ref(new Date().getFullYear());
|
||||||
|
const pickerStyle = ref<Record<string, string>>({});
|
||||||
|
const pickerEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
|
||||||
|
|
||||||
|
function handleDatesSet(arg: { view: { currentStart: Date } }) {
|
||||||
|
const d = arg.view.currentStart;
|
||||||
|
currentViewYear.value = d.getFullYear();
|
||||||
|
currentViewMonth.value = d.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpTo(year: number, month: number) {
|
||||||
|
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
|
||||||
|
pickerOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event popover ──────────────────────────────────────────────────────────
|
// ── Event popover ──────────────────────────────────────────────────────────
|
||||||
const popover = ref<EventEntry | null>(null);
|
const popover = ref<EventEntry | null>(null);
|
||||||
const popoverStyle = ref<Record<string, string>>({});
|
const popoverStyle = ref<Record<string, string>>({});
|
||||||
@@ -108,9 +129,32 @@ function onPopoverEdit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover on outside click
|
// Close popover / open picker on outside or title click
|
||||||
function onDocClick(e: MouseEvent) {
|
function onDocClick(e: MouseEvent) {
|
||||||
if (popover.value && popoverEl.value && !popoverEl.value.contains(e.target as Node)) {
|
const target = e.target as HTMLElement;
|
||||||
|
// Title click → toggle month/year picker
|
||||||
|
const titleEl = target.closest(".fc-toolbar-title");
|
||||||
|
if (titleEl) {
|
||||||
|
if (!pickerOpen.value) {
|
||||||
|
pickerYear.value = currentViewYear.value;
|
||||||
|
const rect = titleEl.getBoundingClientRect();
|
||||||
|
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
|
||||||
|
pickerStyle.value = {
|
||||||
|
position: "fixed",
|
||||||
|
top: `${rect.bottom + 6}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
zIndex: "9999",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pickerOpen.value = !pickerOpen.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Close picker on outside click
|
||||||
|
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
|
||||||
|
pickerOpen.value = false;
|
||||||
|
}
|
||||||
|
// Close event popover on outside click
|
||||||
|
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
|
||||||
closePopover();
|
closePopover();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +269,7 @@ const calendarOptions: CalendarOptions = {
|
|||||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||||
},
|
},
|
||||||
events: loadEvents,
|
events: loadEvents,
|
||||||
|
datesSet: handleDatesSet,
|
||||||
dateClick: handleDateClick,
|
dateClick: handleDateClick,
|
||||||
eventClick: handleEventClick,
|
eventClick: handleEventClick,
|
||||||
eventDrop: handleEventDrop,
|
eventDrop: handleEventDrop,
|
||||||
@@ -334,6 +379,26 @@ const upcomingGrouped = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- ── Month/year picker ──────────────────────────────────────────── -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
|
||||||
|
<div class="picker-year-row">
|
||||||
|
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year">‹</button>
|
||||||
|
<span class="picker-year-label">{{ pickerYear }}</span>
|
||||||
|
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="picker-months">
|
||||||
|
<button
|
||||||
|
v-for="(name, i) in MONTH_NAMES"
|
||||||
|
:key="i"
|
||||||
|
class="picker-month"
|
||||||
|
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
|
||||||
|
@click="jumpTo(pickerYear, i)"
|
||||||
|
>{{ name }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<EventSlideOver
|
<EventSlideOver
|
||||||
v-if="slideOverOpen"
|
v-if="slideOverOpen"
|
||||||
:event="slideOverEvent"
|
:event="slideOverEvent"
|
||||||
@@ -396,6 +461,14 @@ const upcomingGrouped = computed(() => {
|
|||||||
:deep(.fc-toolbar-title) {
|
:deep(.fc-toolbar-title) {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
:deep(.fc-toolbar-title:hover) {
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
}
|
}
|
||||||
:deep(.fc-button) {
|
:deep(.fc-button) {
|
||||||
background: var(--color-input-bg, var(--color-bg));
|
background: var(--color-input-bg, var(--color-bg));
|
||||||
@@ -439,6 +512,71 @@ const upcomingGrouped = computed(() => {
|
|||||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
/* ── Month/year picker ──────────────────────────────────────────────────── */
|
||||||
|
.month-picker {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
width: 280px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-year-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-year-label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-year-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border, #2a2b30);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.picker-year-btn:hover {
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
border-color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-months {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-month {
|
||||||
|
padding: 7px 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text, #e8e9f0);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.picker-month:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.picker-month.active {
|
||||||
|
background: rgba(99,102,241,0.22);
|
||||||
|
color: var(--color-primary, #818cf8);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||||||
.upcoming-section {
|
.upcoming-section {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user