Files
clientsflow/frontend/app/components/workspace/calendar/CrmCalendarPanel.vue
Ruslan Bakiev e5ad3809e0 feat(calendar): flying label animation from card title to toolbar on zoom
The label (month name, week number, day label) now animates from its
position above the source card to the toolbar center on zoom-in, and
flies back from toolbar to the target card title on zoom-out. The
fly-rect rectangle no longer contains text — only skeleton placeholder
lines. Sibling card titles and week numbers also fade during zoom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:03:16 +07:00

652 lines
22 KiB
Vue

<script setup lang="ts">
type CalendarEvent = {
id: string;
title: string;
start: string;
end: string;
contact: string;
note: string;
};
type YearMonthItem = {
monthIndex: number;
label: string;
count: number;
first?: CalendarEvent;
};
type MonthCell = {
key: string;
day: number;
inMonth: boolean;
events: CalendarEvent[];
};
type MonthRow = {
key: string;
startKey: string;
weekNumber: number;
cells: MonthCell[];
};
type WeekDay = {
key: string;
label: string;
day: number;
events: CalendarEvent[];
};
defineProps<{
contextPickerEnabled: boolean;
hasContextScope: (scope: "calendar") => boolean;
toggleContextScope: (scope: "calendar") => void;
contextScopeLabel: (scope: "calendar") => string;
setToday: () => void;
calendarPeriodLabel: string;
calendarZoomLevelIndex: number;
onCalendarZoomSliderInput: (event: Event) => void;
focusedCalendarEvent: CalendarEvent | null;
formatDay: (iso: string) => string;
formatTime: (iso: string) => string;
setCalendarContentWrapRef: (element: HTMLDivElement | null) => void;
shiftCalendar: (step: number) => void;
setCalendarContentScrollRef: (element: HTMLDivElement | null) => void;
onCalendarHierarchyWheel: (event: WheelEvent) => void;
setCalendarSceneRef: (element: HTMLDivElement | null) => void;
calendarViewportHeight: number;
normalizedCalendarView: string;
onCalendarSceneMouseLeave: () => void;
calendarView: string;
yearMonths: YearMonthItem[];
calendarCursorMonth: number;
calendarHoveredMonthIndex: number | null;
setCalendarHoveredMonthIndex: (value: number | null) => void;
calendarZoomPrimeToken: string;
calendarPrimeMonthToken: (monthIndex: number) => string;
calendarPrimeStyle: (token: string) => Record<string, string>;
zoomToMonth: (monthIndex: number) => void;
openThreadFromCalendarItem: (event: CalendarEvent) => void;
monthRows: MonthRow[];
calendarHoveredWeekStartKey: string;
setCalendarHoveredWeekStartKey: (value: string) => void;
calendarPrimeWeekToken: (startKey: string) => string;
selectedDateKey: string;
monthCellHasFocusedEvent: (events: CalendarEvent[]) => boolean;
calendarHoveredDayKey: string;
setCalendarHoveredDayKey: (value: string) => void;
pickDate: (key: string) => void;
monthCellEvents: (events: CalendarEvent[]) => CalendarEvent[];
isReviewHighlightedEvent: (eventId: string) => boolean;
weekDays: WeekDay[];
calendarPrimeDayToken: (dayKey: string) => string;
selectedDayEvents: CalendarEvent[];
calendarFlyVisible: boolean;
setCalendarFlyRectRef: (element: HTMLDivElement | null) => void;
calendarFlyLabelVisible: boolean;
setCalendarFlyLabelRef: (element: HTMLDivElement | null) => void;
setCalendarToolbarLabelRef: (element: HTMLDivElement | null) => void;
}>();
</script>
<template>
<section
class="relative flex h-full min-h-0 flex-col gap-3"
:class="[
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
hasContextScope('calendar') ? 'context-scope-block-selected' : '',
]"
@click="toggleContextScope('calendar')"
>
<span
v-if="contextPickerEnabled"
class="context-scope-label"
>{{ contextScopeLabel('calendar') }}</span>
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-1">
<button class="btn btn-xs" @click="setToday">Today</button>
</div>
<div :ref="setCalendarToolbarLabelRef" class="text-center text-sm font-medium">
{{ calendarPeriodLabel }}
</div>
<div class="justify-self-end calendar-zoom-inline" @click.stop>
<input
class="calendar-zoom-slider"
type="range"
min="0"
max="3"
step="1"
:value="calendarZoomLevelIndex"
aria-label="Calendar zoom level"
@input="onCalendarZoomSliderInput"
>
<div class="calendar-zoom-marks" aria-hidden="true">
<span
v-for="index in 4"
:key="`calendar-zoom-mark-${index}`"
class="calendar-zoom-mark"
:class="calendarZoomLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
/>
</div>
</div>
</div>
<article
v-if="focusedCalendarEvent"
class="rounded-xl border border-success/50 bg-success/10 px-3 py-2"
>
<p class="text-xs font-semibold uppercase tracking-wide text-success/80">Review focus event</p>
<p class="text-sm font-medium text-base-content">{{ focusedCalendarEvent.title }}</p>
<p class="text-xs text-base-content/70">
{{ formatDay(focusedCalendarEvent.start) }} · {{ formatTime(focusedCalendarEvent.start) }} - {{ formatTime(focusedCalendarEvent.end) }}
</p>
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
</article>
<!-- GSAP flying label (title transition overlay) -->
<div
v-show="calendarFlyLabelVisible"
:ref="setCalendarFlyLabelRef"
class="calendar-fly-label-el"
/>
<div :ref="setCalendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
<button
class="calendar-side-nav calendar-side-nav-left"
type="button"
title="Previous period"
@click="shiftCalendar(-1)"
>
<span></span>
</button>
<button
class="calendar-side-nav calendar-side-nav-right"
type="button"
title="Next period"
@click="shiftCalendar(1)"
>
<span></span>
</button>
<!-- GSAP flying rect (zoom transition overlay) -->
<div
v-show="calendarFlyVisible"
:ref="setCalendarFlyRectRef"
class="calendar-fly-rect"
/>
<div
:ref="setCalendarContentScrollRef"
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel"
>
<div
:ref="setCalendarSceneRef"
:class="[
'calendar-scene',
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
]"
@mouseleave="onCalendarSceneMouseLeave"
>
<div
class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 auto-rows-fr"
:style="calendarViewportHeight > 0 ? { minHeight: `${calendarView === 'year' ? Math.max(420, calendarViewportHeight) : calendarViewportHeight}px` } : undefined"
>
<div
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
v-show="calendarView === 'year' || item.monthIndex === calendarCursorMonth"
:class="[
calendarView === 'year' ? 'flex flex-col h-full' : 'sm:col-span-2 xl:col-span-3 flex flex-col',
]"
>
<p
v-if="calendarView === 'year'"
class="calendar-card-title"
>{{ item.label }}</p>
<article
class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable flex-1"
:class="[
calendarView === 'year'
? 'hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in'
: 'cursor-default bg-base-100 flex flex-col',
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]"
:style="{
...calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex)),
...(calendarView !== 'year' && item.monthIndex === calendarCursorMonth && calendarViewportHeight > 0
? { minHeight: `${calendarViewportHeight}px` }
: {}),
}"
:data-calendar-month-index="item.monthIndex"
@mouseenter="setCalendarHoveredMonthIndex(item.monthIndex)"
@click="calendarView === 'year' ? zoomToMonth(item.monthIndex) : undefined"
>
<p v-if="calendarView === 'year'" class="text-xs text-base-content/60">{{ item.count }} events</p>
<button
v-if="calendarView === 'year' && item.first"
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
@click.stop="openThreadFromCalendarItem(item.first)"
>
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</button>
<div v-if="item.monthIndex === calendarCursorMonth" class="mt-3 calendar-depth-stack">
<div
class="space-y-1 calendar-depth-layer"
data-calendar-layer="month"
:class="calendarView === 'month' || calendarView === 'agenda' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<div class="flex items-center gap-1">
<span class="calendar-week-number" aria-hidden="true"></span>
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60 flex-1">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
</div>
<div class="flex flex-1 flex-col gap-1">
<div
v-for="row in monthRows"
:key="row.key"
class="group relative flex-1 flex items-stretch gap-1 calendar-hover-targetable"
:class="[
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
:data-calendar-week-start-key="row.startKey"
@mouseenter="setCalendarHoveredWeekStartKey(row.startKey)"
>
<span class="calendar-week-number">{{ row.weekNumber }}</span>
<div class="grid grid-cols-7 gap-1 h-full flex-1">
<button
v-for="cell in row.cells"
:key="cell.key"
class="group relative rounded-lg border p-1 text-left"
:class="[
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
]"
:data-calendar-day-key="cell.key"
@mouseenter="setCalendarHoveredDayKey(cell.key)"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
v-for="event in monthCellEvents(cell.events)"
:key="event.id"
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} {{ event.title }}
</button>
</button>
</div>
</div>
</div>
</div>
<div
class="calendar-week-scroll h-full min-h-0 overflow-x-auto pb-1 calendar-depth-layer"
data-calendar-layer="week"
:class="calendarView === 'week' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<div class="calendar-week-grid">
<div
v-for="day in weekDays"
:key="day.key"
class="flex flex-col min-h-full"
>
<p class="calendar-card-title text-center">{{ day.label }} {{ day.day }}</p>
<article
class="group relative flex flex-1 flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
:class="[
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
:data-calendar-day-key="day.key"
@mouseenter="setCalendarHoveredDayKey(day.key)"
@click="pickDate(day.key)"
>
<div class="space-y-1.5">
<button
v-for="event in day.events"
:key="event.id"
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
</button>
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
</div>
</article>
</div>
</div>
</div>
<div
class="space-y-2 calendar-depth-layer"
data-calendar-layer="day"
:class="calendarView === 'day' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
>
<button
v-for="event in selectedDayEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@click="openThreadFromCalendarItem(event)"
>
<p class="font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
</div>
</div>
</article>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.calendar-content-wrap {
position: relative;
padding-left: 40px;
padding-right: 40px;
}
.calendar-content-scroll {
height: 100%;
overscroll-behavior: contain;
}
.calendar-scene {
min-height: 100%;
min-width: 100%;
transform-origin: center center;
}
.calendar-scene.cursor-zoom-in,
.calendar-scene.cursor-zoom-in * {
cursor: zoom-in;
}
.calendar-scene.cursor-zoom-out,
.calendar-scene.cursor-zoom-out * {
cursor: zoom-out;
}
.calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, minmax(165px, 1fr));
gap: 8px;
min-width: 1180px;
min-height: 100%;
align-items: stretch;
}
.calendar-depth-stack {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.calendar-depth-layer {
transition: opacity 260ms ease, transform 260ms ease;
}
.calendar-depth-layer-active {
opacity: 1;
transform: translateY(0);
position: relative;
z-index: 1;
pointer-events: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.calendar-depth-layer-hidden {
opacity: 0;
transform: translateY(10px);
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.calendar-side-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 4;
width: 28px;
height: 28px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
color: color-mix(in oklab, var(--color-base-content) 86%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.calendar-side-nav:hover {
border-color: color-mix(in oklab, var(--color-primary) 50%, transparent);
background: color-mix(in oklab, var(--color-primary) 14%, var(--color-base-100));
transform: translateY(-50%) scale(1.03);
}
.calendar-side-nav-left {
left: 4px;
}
.calendar-side-nav-right {
right: 4px;
}
.calendar-card-title {
font-size: 11px;
font-weight: 600;
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
padding: 0 4px 2px;
user-select: none;
}
.calendar-week-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
min-width: 24px;
font-size: 10px;
font-weight: 500;
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
user-select: none;
}
.calendar-hover-targetable {
transform-origin: center center;
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
}
.calendar-hover-target {
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
outline-offset: 1px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
}
.calendar-zoom-prime-active {
z-index: 2;
}
.calendar-fly-rect {
position: absolute;
z-index: 20;
pointer-events: none;
will-change: left, top, width, height;
}
.calendar-fly-label-el {
position: absolute;
z-index: 30;
pointer-events: none;
white-space: nowrap;
will-change: left, top, font-size;
}
.calendar-zoom-inline {
position: relative;
display: flex;
align-items: center;
width: 128px;
height: 22px;
padding: 0 10px;
}
.calendar-zoom-slider {
width: 100%;
height: 18px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.calendar-zoom-slider:focus-visible {
outline: none;
}
.calendar-zoom-slider::-webkit-slider-runnable-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
margin-top: -4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
border: 0;
}
.calendar-zoom-slider::-moz-range-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-moz-range-progress {
height: 2px;
border-radius: 999px;
background: transparent;
}
.calendar-zoom-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
}
.calendar-zoom-marks {
position: absolute;
inset: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.calendar-zoom-mark {
width: 4px;
height: 4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
}
.calendar-zoom-mark-active {
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
}
@media (max-width: 960px) {
.calendar-content-wrap {
padding-left: 32px;
padding-right: 32px;
}
.calendar-week-grid {
grid-template-columns: repeat(7, minmax(150px, 1fr));
min-width: 1060px;
}
.calendar-side-nav {
width: 24px;
height: 24px;
}
.calendar-zoom-inline {
width: 108px;
}
}
</style>
<style>
/* Non-scoped: fly-rect inner content is injected via innerHTML */
.calendar-fly-rect .calendar-fly-content {
display: flex;
flex-direction: column;
padding: 12px 16px;
height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.calendar-fly-rect .calendar-fly-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.calendar-fly-rect .calendar-fly-skeleton-line {
height: 8px;
border-radius: 4px;
background: color-mix(in oklab, var(--color-base-content) 10%, transparent);
animation: calendar-fly-skeleton-pulse 0.8s ease-in-out infinite alternate;
}
@keyframes calendar-fly-skeleton-pulse {
from { opacity: 0.3; }
to { opacity: 0.7; }
}
</style>