feat(calendar): seamless zoom animation with clone-and-swap + full-area coverage
Zoom-in: fade siblings → fade source content → clone source style to fly-rect → hide source → animate fly-rect to viewport → switch view → fade in new content. Zoom-out: fade scene → show fly-rect at viewport → switch view → clone target style → animate fly-rect to target → fade in scene. Full-area: all views (month/week/day) now fill 100% of container height. Month grid rows stretch equally, day cells fill row height, depth layers flex. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2678,6 +2678,24 @@ function nextAnimationFrame() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneElementStyleToFlyRect(source: HTMLElement, flyEl: HTMLElement) {
|
||||||
|
const s = getComputedStyle(source);
|
||||||
|
flyEl.style.borderColor = s.borderColor;
|
||||||
|
flyEl.style.borderWidth = s.borderWidth;
|
||||||
|
flyEl.style.borderStyle = s.borderStyle;
|
||||||
|
flyEl.style.backgroundColor = s.backgroundColor;
|
||||||
|
flyEl.style.borderRadius = s.borderRadius;
|
||||||
|
flyEl.style.boxShadow = s.boxShadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFlyRectStyle(flyEl: HTMLElement) {
|
||||||
|
flyEl.style.borderColor = "";
|
||||||
|
flyEl.style.borderWidth = "";
|
||||||
|
flyEl.style.borderStyle = "";
|
||||||
|
flyEl.style.backgroundColor = "";
|
||||||
|
flyEl.style.borderRadius = "";
|
||||||
|
flyEl.style.boxShadow = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function animateCalendarFlipTransition(
|
async function animateCalendarFlipTransition(
|
||||||
_sourceElement: HTMLElement | null,
|
_sourceElement: HTMLElement | null,
|
||||||
@@ -2701,55 +2719,71 @@ async function animateCalendarFlipTransition(
|
|||||||
try {
|
try {
|
||||||
const wrapRect = wrapEl.getBoundingClientRect();
|
const wrapRect = wrapEl.getBoundingClientRect();
|
||||||
|
|
||||||
// 1. Fade out current content
|
// 1. Fade out current scene content
|
||||||
if (sceneEl) {
|
if (sceneEl) {
|
||||||
await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
|
await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Position fly rect at full viewport
|
// 2. Position fly rect at full viewport, styled like a card
|
||||||
const pad = 4;
|
const pad = 0;
|
||||||
gsap.set(flyEl, {
|
gsap.set(flyEl, {
|
||||||
left: pad,
|
left: pad,
|
||||||
top: pad,
|
top: pad,
|
||||||
width: wrapRect.width - pad * 2,
|
width: wrapRect.width - pad * 2,
|
||||||
height: wrapRect.height - pad * 2,
|
height: wrapRect.height - pad * 2,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
borderRadius: 14,
|
|
||||||
});
|
});
|
||||||
|
// Apply card-like styling: border + bg matching article cards
|
||||||
|
flyEl.style.borderRadius = "0.75rem";
|
||||||
|
flyEl.style.borderWidth = "1px";
|
||||||
|
flyEl.style.borderStyle = "solid";
|
||||||
|
flyEl.style.borderColor = "color-mix(in oklab, var(--color-base-300) 100%, transparent)";
|
||||||
|
flyEl.style.backgroundColor = "color-mix(in oklab, var(--color-base-100) 100%, transparent)";
|
||||||
|
flyEl.style.boxShadow = "";
|
||||||
calendarFlyVisible.value = true;
|
calendarFlyVisible.value = true;
|
||||||
|
|
||||||
// 3. Switch to parent view
|
// 3. Switch to parent view
|
||||||
apply();
|
apply();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
await nextAnimationFrame();
|
||||||
|
|
||||||
// 4. Find target element in new view
|
// 4. Find target element in new view
|
||||||
const targetElement = resolveTarget();
|
const targetElement = resolveTarget();
|
||||||
const targetRect = targetElement?.getBoundingClientRect() ?? null;
|
const targetRect = targetElement?.getBoundingClientRect() ?? null;
|
||||||
|
|
||||||
if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
|
if (targetElement && targetRect && targetRect.width >= 2 && targetRect.height >= 2) {
|
||||||
|
// Clone target's visual style to fly rect for seamless landing
|
||||||
|
cloneElementStyleToFlyRect(targetElement, flyEl);
|
||||||
|
// Hide target so there's no double
|
||||||
|
targetElement.style.opacity = "0";
|
||||||
|
|
||||||
const tgtLeft = targetRect.left - wrapRect.left;
|
const tgtLeft = targetRect.left - wrapRect.left;
|
||||||
const tgtTop = targetRect.top - wrapRect.top;
|
const tgtTop = targetRect.top - wrapRect.top;
|
||||||
|
|
||||||
// 5. Animate fly rect → target element
|
// 5. Animate fly rect → target element bounds
|
||||||
await calendarTweenTo(flyEl, {
|
await calendarTweenTo(flyEl, {
|
||||||
left: tgtLeft,
|
left: tgtLeft,
|
||||||
top: tgtTop,
|
top: tgtTop,
|
||||||
width: targetRect.width,
|
width: targetRect.width,
|
||||||
height: targetRect.height,
|
height: targetRect.height,
|
||||||
borderRadius: 12,
|
|
||||||
duration: CALENDAR_FLY_DURATION,
|
duration: CALENDAR_FLY_DURATION,
|
||||||
ease: CALENDAR_EASE,
|
ease: CALENDAR_EASE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore target visibility
|
||||||
|
targetElement.style.opacity = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Hide fly rect, fade in content
|
// 6. Hide fly rect, fade in content
|
||||||
calendarFlyVisible.value = false;
|
calendarFlyVisible.value = false;
|
||||||
|
resetFlyRectStyle(flyEl);
|
||||||
if (sceneEl) {
|
if (sceneEl) {
|
||||||
gsap.set(sceneEl, { opacity: 0 });
|
gsap.set(sceneEl, { opacity: 0 });
|
||||||
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
|
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
calendarFlyVisible.value = false;
|
calendarFlyVisible.value = false;
|
||||||
|
resetFlyRectStyle(flyEl);
|
||||||
calendarZoomBusy.value = false;
|
calendarZoomBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2787,7 +2821,12 @@ async function animateCalendarZoomIntoSource(
|
|||||||
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
|
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
|
||||||
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
|
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
|
||||||
|
|
||||||
// 2. Position fly rect at source element
|
// 2. Fade out source element's inner content (keep border/bg visible)
|
||||||
|
const sourceChildren = Array.from(sourceElement.children) as HTMLElement[];
|
||||||
|
await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" });
|
||||||
|
|
||||||
|
// 3. Clone source visual style to fly-rect, position at source bounds
|
||||||
|
cloneElementStyleToFlyRect(sourceElement, flyEl);
|
||||||
const srcLeft = sourceRect.left - wrapRect.left;
|
const srcLeft = sourceRect.left - wrapRect.left;
|
||||||
const srcTop = sourceRect.top - wrapRect.top;
|
const srcTop = sourceRect.top - wrapRect.top;
|
||||||
gsap.set(flyEl, {
|
gsap.set(flyEl, {
|
||||||
@@ -2796,39 +2835,46 @@ async function animateCalendarZoomIntoSource(
|
|||||||
width: sourceRect.width,
|
width: sourceRect.width,
|
||||||
height: sourceRect.height,
|
height: sourceRect.height,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
borderRadius: 12,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 4. Swap: hide source, show fly-rect (seamless — identical visual)
|
||||||
|
sourceElement.style.opacity = "0";
|
||||||
calendarFlyVisible.value = true;
|
calendarFlyVisible.value = true;
|
||||||
|
|
||||||
// 3. Animate fly rect → full viewport
|
// 5. Animate fly-rect expanding to full viewport
|
||||||
const pad = 4;
|
const pad = 0;
|
||||||
await calendarTweenTo(flyEl, {
|
await calendarTweenTo(flyEl, {
|
||||||
left: pad,
|
left: pad,
|
||||||
top: pad,
|
top: pad,
|
||||||
width: wrapRect.width - pad * 2,
|
width: wrapRect.width - pad * 2,
|
||||||
height: wrapRect.height - pad * 2,
|
height: wrapRect.height - pad * 2,
|
||||||
borderRadius: 14,
|
|
||||||
duration: CALENDAR_FLY_DURATION,
|
duration: CALENDAR_FLY_DURATION,
|
||||||
ease: CALENDAR_EASE,
|
ease: CALENDAR_EASE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Switch content
|
// 6. Switch view content
|
||||||
apply();
|
apply();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// 5. Hide fly rect, fade in new content
|
// 7. Hide fly-rect, fade in new content
|
||||||
calendarFlyVisible.value = false;
|
calendarFlyVisible.value = false;
|
||||||
|
resetFlyRectStyle(flyEl);
|
||||||
if (sceneEl) {
|
if (sceneEl) {
|
||||||
gsap.set(sceneEl, { opacity: 0 });
|
gsap.set(sceneEl, { opacity: 0 });
|
||||||
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
|
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Restore sibling opacity
|
// 8. Restore source + siblings
|
||||||
|
sourceElement.style.opacity = "";
|
||||||
|
for (const child of sourceChildren) {
|
||||||
|
child.style.opacity = "";
|
||||||
|
}
|
||||||
for (const el of siblings) {
|
for (const el of siblings) {
|
||||||
el.style.opacity = "";
|
el.style.opacity = "";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
calendarFlyVisible.value = false;
|
calendarFlyVisible.value = false;
|
||||||
|
resetFlyRectStyle(flyEl);
|
||||||
calendarZoomBusy.value = false;
|
calendarZoomBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ defineProps<{
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 auto-rows-fr"
|
class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 auto-rows-fr"
|
||||||
:style="calendarView === 'year' && calendarViewportHeight > 0 ? { minHeight: `${Math.max(420, calendarViewportHeight)}px` } : undefined"
|
:style="calendarViewportHeight > 0 ? { minHeight: `${calendarView === 'year' ? Math.max(420, calendarViewportHeight) : calendarViewportHeight}px` } : undefined"
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
v-for="item in yearMonths"
|
v-for="item in yearMonths"
|
||||||
@@ -190,14 +190,14 @@ defineProps<{
|
|||||||
:class="[
|
:class="[
|
||||||
calendarView === 'year'
|
calendarView === 'year'
|
||||||
? 'h-full hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in'
|
? 'h-full hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in'
|
||||||
: 'cursor-default min-h-[26rem] bg-base-100 sm:col-span-2 xl:col-span-3',
|
: 'cursor-default bg-base-100 sm:col-span-2 xl:col-span-3 flex flex-col',
|
||||||
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
|
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
|
||||||
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
|
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
|
||||||
]"
|
]"
|
||||||
:style="{
|
:style="{
|
||||||
...calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex)),
|
...calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex)),
|
||||||
...(calendarView !== 'year' && item.monthIndex === calendarCursorMonth && calendarViewportHeight > 0
|
...(calendarView !== 'year' && item.monthIndex === calendarCursorMonth && calendarViewportHeight > 0
|
||||||
? { minHeight: `${Math.max(420, calendarViewportHeight)}px` }
|
? { minHeight: `${calendarViewportHeight}px` }
|
||||||
: {}),
|
: {}),
|
||||||
}"
|
}"
|
||||||
:data-calendar-month-index="item.monthIndex"
|
:data-calendar-month-index="item.monthIndex"
|
||||||
@@ -230,11 +230,11 @@ defineProps<{
|
|||||||
<span>Sat</span>
|
<span>Sat</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
v-for="row in monthRows"
|
v-for="row in monthRows"
|
||||||
:key="row.key"
|
:key="row.key"
|
||||||
class="group relative calendar-hover-targetable"
|
class="group relative flex-1 calendar-hover-targetable"
|
||||||
:class="[
|
:class="[
|
||||||
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
|
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
|
||||||
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
|
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
|
||||||
@@ -243,11 +243,11 @@ defineProps<{
|
|||||||
:data-calendar-week-start-key="row.startKey"
|
:data-calendar-week-start-key="row.startKey"
|
||||||
@mouseenter="setCalendarHoveredWeekStartKey(row.startKey)"
|
@mouseenter="setCalendarHoveredWeekStartKey(row.startKey)"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-7 gap-1">
|
<div class="grid grid-cols-7 gap-1 h-full">
|
||||||
<button
|
<button
|
||||||
v-for="cell in row.cells"
|
v-for="cell in row.cells"
|
||||||
:key="cell.key"
|
:key="cell.key"
|
||||||
class="group relative min-h-24 rounded-lg border p-1 text-left"
|
class="group relative rounded-lg border p-1 text-left"
|
||||||
:class="[
|
:class="[
|
||||||
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
|
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' : '',
|
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
|
||||||
@@ -379,7 +379,10 @@ defineProps<{
|
|||||||
|
|
||||||
.calendar-depth-stack {
|
.calendar-depth-stack {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100%;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-depth-layer {
|
.calendar-depth-layer {
|
||||||
@@ -392,6 +395,9 @@ defineProps<{
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-depth-layer-hidden {
|
.calendar-depth-layer-hidden {
|
||||||
@@ -451,9 +457,6 @@ defineProps<{
|
|||||||
|
|
||||||
.calendar-fly-rect {
|
.calendar-fly-rect {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 12px;
|
|
||||||
border: 2px solid color-mix(in oklab, var(--color-primary) 70%, transparent);
|
|
||||||
background: color-mix(in oklab, var(--color-base-200) 60%, transparent);
|
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
will-change: left, top, width, height;
|
will-change: left, top, width, height;
|
||||||
|
|||||||
Reference in New Issue
Block a user