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>
This commit is contained in:
Ruslan Bakiev
2026-02-24 15:03:16 +07:00
parent 00e036946c
commit e5ad3809e0
2 changed files with 212 additions and 66 deletions

View File

@@ -2468,6 +2468,18 @@ function setCalendarFlyRectRef(element: HTMLDivElement | null) {
calendarFlyRectRef.value = element;
}
const calendarFlyLabelRef = ref<HTMLDivElement | null>(null);
const calendarFlyLabelVisible = ref(false);
const calendarToolbarLabelRef = ref<HTMLDivElement | null>(null);
function setCalendarFlyLabelRef(element: HTMLDivElement | null) {
calendarFlyLabelRef.value = element;
}
function setCalendarToolbarLabelRef(element: HTMLDivElement | null) {
calendarToolbarLabelRef.value = element;
}
function calendarTweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise<void> {
return new Promise((resolve) => {
const t = gsap.to(target, {
@@ -2722,29 +2734,32 @@ function extractSourceLabel(sourceElement: HTMLElement, viewBefore: string): str
return "";
}
function buildFlyRectContent(labelText: string): string {
const skeleton = `
<div class="calendar-fly-skeleton">
function findSourceTitleElement(sourceElement: HTMLElement, viewBefore: string): HTMLElement | null {
if (viewBefore === "year") {
return sourceElement.parentElement?.querySelector<HTMLElement>(".calendar-card-title") ?? null;
}
if (viewBefore === "month" || viewBefore === "agenda") {
return sourceElement.querySelector<HTMLElement>(".calendar-week-number") ?? null;
}
if (viewBefore === "week") {
return sourceElement.parentElement?.querySelector<HTMLElement>(".calendar-card-title") ?? null;
}
return null;
}
function resetFlyLabelStyle(el: HTMLElement) {
el.textContent = "";
el.style.fontWeight = "";
el.style.color = "";
el.style.fontSize = "";
}
function buildFlyRectSkeletonContent(): string {
return `<div class="calendar-fly-content"><div class="calendar-fly-skeleton">
<div class="calendar-fly-skeleton-line" style="width:70%"></div>
<div class="calendar-fly-skeleton-line" style="width:45%"></div>
<div class="calendar-fly-skeleton-line" style="width:60%"></div>
</div>
`;
return `<div class="calendar-fly-content"><p class="calendar-fly-label">${labelText}</p>${skeleton}</div>`;
}
function buildFlyRectContentForZoomOut(currentView: string): string {
let labelText = "";
if (currentView === "day") {
const d = new Date(`${selectedDateKey.value}T00:00:00`);
labelText = new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d) + " " + d.getDate();
} else if (currentView === "week") {
const weekStart = weekRowStartForDate(selectedDateKey.value);
labelText = `Week ${isoWeekNumber(weekStart)}`;
} else if (currentView === "month" || currentView === "agenda") {
labelText = new Intl.DateTimeFormat("en-US", { month: "long" }).format(calendarCursor.value);
}
return buildFlyRectContent(labelText);
</div></div>`;
}
async function animateCalendarFlipTransition(
@@ -2759,6 +2774,8 @@ async function animateCalendarFlipTransition(
const flyEl = calendarFlyRectRef.value;
const wrapEl = calendarContentWrapRef.value;
const sceneEl = calendarSceneRef.value;
const flyLabelEl = calendarFlyLabelRef.value;
const toolbarLabelEl = calendarToolbarLabelRef.value;
if (!flyEl || !wrapEl) {
apply();
@@ -2769,51 +2786,97 @@ async function animateCalendarFlipTransition(
try {
const wrapRect = wrapEl.getBoundingClientRect();
// Capture toolbar label text BEFORE view change
const flyLabelText = calendarPeriodLabel.value;
// 1. Fade out current scene content
if (sceneEl) {
await calendarTweenTo(sceneEl, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
}
// 2. Position fly rect at full viewport, styled like a card
const pad = 0;
gsap.set(flyEl, {
left: pad,
top: pad,
width: wrapRect.width - pad * 2,
height: wrapRect.height - pad * 2,
left: 0,
top: 0,
width: wrapRect.width,
height: wrapRect.height,
opacity: 1,
});
// 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 = "";
// Inject label + skeleton for zoom-out
flyEl.innerHTML = buildFlyRectContentForZoomOut(calendarView.value);
flyEl.innerHTML = buildFlyRectSkeletonContent();
calendarFlyVisible.value = true;
// 3. Switch to parent view
// 3. Position flying label at toolbar position
let flyLabelReady = false;
if (flyLabelEl && toolbarLabelEl && flyLabelText) {
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
if (sectionRect) {
const toolbarStyle = getComputedStyle(toolbarLabelEl);
// Measure exact text position (centered via text-align)
const toolbarTextRange = document.createRange();
toolbarTextRange.selectNodeContents(toolbarLabelEl);
const toolbarTextRect = toolbarTextRange.getBoundingClientRect();
flyLabelEl.textContent = flyLabelText;
flyLabelEl.style.fontWeight = toolbarStyle.fontWeight;
flyLabelEl.style.color = toolbarStyle.color;
gsap.set(flyLabelEl, {
left: toolbarTextRect.left - sectionRect.left,
top: toolbarTextRect.top - sectionRect.top,
fontSize: parseFloat(toolbarStyle.fontSize),
opacity: 1,
});
toolbarLabelEl.style.opacity = "0";
calendarFlyLabelVisible.value = true;
flyLabelReady = true;
}
}
// 4. Switch to parent view
apply();
await nextTick();
await nextAnimationFrame();
// 4. Find target element in new view
// 5. Find target element in new view
const targetElement = resolveTarget();
const targetRect = targetElement?.getBoundingClientRect() ?? null;
const viewAfter = calendarView.value;
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 tgtTop = targetRect.top - wrapRect.top;
// 5. Animate fly rect → target element bounds
await calendarTweenTo(flyEl, {
// Find target's title element for fly-label destination
const targetTitleEl = findSourceTitleElement(targetElement, viewAfter);
let flyLabelPromise: Promise<void> | null = null;
if (flyLabelReady && flyLabelEl && targetTitleEl) {
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
if (sectionRect) {
const titleRect = targetTitleEl.getBoundingClientRect();
const titleStyle = getComputedStyle(targetTitleEl);
targetTitleEl.style.opacity = "0";
flyLabelPromise = calendarTweenTo(flyLabelEl, {
left: titleRect.left - sectionRect.left,
top: titleRect.top - sectionRect.top,
fontSize: parseFloat(titleStyle.fontSize),
color: titleStyle.color,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
}
}
// 6. Animate fly rect → target bounds (concurrent with label flight)
const flyRectPromise = calendarTweenTo(flyEl, {
left: tgtLeft,
top: tgtTop,
width: targetRect.width,
@@ -2822,11 +2885,19 @@ async function animateCalendarFlipTransition(
ease: CALENDAR_EASE,
});
// Restore target visibility
await Promise.all([flyRectPromise, flyLabelPromise].filter(Boolean));
// Restore visibility
targetElement.style.opacity = "";
if (targetTitleEl) targetTitleEl.style.opacity = "";
}
// 6. Hide fly rect, fade in content
// 7. Cleanup flying label
calendarFlyLabelVisible.value = false;
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
// 8. Hide fly rect, fade in content
calendarFlyVisible.value = false;
resetFlyRectStyle(flyEl);
if (sceneEl) {
@@ -2835,7 +2906,10 @@ async function animateCalendarFlipTransition(
}
} finally {
calendarFlyVisible.value = false;
calendarFlyLabelVisible.value = false;
resetFlyRectStyle(flyEl);
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
calendarZoomBusy.value = false;
}
}
@@ -2852,6 +2926,8 @@ async function animateCalendarZoomIntoSource(
const wrapEl = calendarContentWrapRef.value;
const scrollEl = calendarContentScrollRef.value;
const sceneEl = calendarSceneRef.value;
const flyLabelEl = calendarFlyLabelRef.value;
const toolbarLabelEl = calendarToolbarLabelRef.value;
if (!sourceElement || !flyEl || !wrapEl || !scrollEl) {
apply();
@@ -2867,22 +2943,28 @@ async function animateCalendarZoomIntoSource(
return;
}
// 1. Extract label before fading children
const labelText = extractSourceLabel(sourceElement, calendarView.value);
// 1. Find source title element and extract label text
const viewBefore = calendarView.value;
const labelText = extractSourceLabel(sourceElement, viewBefore);
const sourceTitleEl = findSourceTitleElement(sourceElement, viewBefore);
// 2. Fade out siblings
const siblings = Array.from(
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable") ?? [],
).filter((el) => el !== sourceElement && !sourceElement.contains(el) && !el.contains(sourceElement));
// 2. Fade out siblings (cards + external titles + week numbers)
const allFadable = Array.from(
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable, .calendar-card-title, .calendar-week-number") ?? [],
);
const siblings = allFadable.filter(
(el) => el !== sourceElement && el !== sourceTitleEl
&& !sourceElement.contains(el) && !el.contains(sourceElement),
);
await calendarTweenTo(siblings, { opacity: 0, duration: CALENDAR_FADE_DURATION, ease: "power2.in" });
// 3. 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" });
// 4. Clone source visual style to fly-rect, inject label + skeleton
// 4. Clone source visual style to fly-rect, inject skeleton (no label text)
cloneElementStyleToFlyRect(sourceElement, flyEl);
flyEl.innerHTML = buildFlyRectContent(labelText);
flyEl.innerHTML = buildFlyRectSkeletonContent();
const srcLeft = sourceRect.left - wrapRect.left;
const srcTop = sourceRect.top - wrapRect.top;
gsap.set(flyEl, {
@@ -2893,26 +2975,76 @@ async function animateCalendarZoomIntoSource(
opacity: 1,
});
// 4. Swap: hide source, show fly-rect (seamless — identical visual)
// 5. Swap: hide source, show fly-rect
sourceElement.style.opacity = "0";
calendarFlyVisible.value = true;
// 5. Animate fly-rect expanding to full viewport
const pad = 0;
await calendarTweenTo(flyEl, {
left: pad,
top: pad,
width: wrapRect.width - pad * 2,
height: wrapRect.height - pad * 2,
// 6. Setup flying label: source title → toolbar
let flyLabelPromise: Promise<void> | null = null;
if (flyLabelEl && toolbarLabelEl && sourceTitleEl && labelText) {
const sectionRect = (flyLabelEl.offsetParent as HTMLElement | null)?.getBoundingClientRect();
if (sectionRect) {
const srcTitleRect = sourceTitleEl.getBoundingClientRect();
const srcTitleStyle = getComputedStyle(sourceTitleEl);
const toolbarStyle = getComputedStyle(toolbarLabelEl);
// Position fly label at source title
flyLabelEl.textContent = labelText;
flyLabelEl.style.fontWeight = srcTitleStyle.fontWeight;
flyLabelEl.style.color = srcTitleStyle.color;
gsap.set(flyLabelEl, {
left: srcTitleRect.left - sectionRect.left,
top: srcTitleRect.top - sectionRect.top,
fontSize: parseFloat(srcTitleStyle.fontSize),
opacity: 1,
});
// Hide source title and toolbar label
sourceTitleEl.style.opacity = "0";
toolbarLabelEl.style.opacity = "0";
calendarFlyLabelVisible.value = true;
// Compute end position: exact text position in toolbar (centered)
await nextAnimationFrame();
const endFontSize = parseFloat(toolbarStyle.fontSize);
const toolbarTextRange = document.createRange();
toolbarTextRange.selectNodeContents(toolbarLabelEl);
const toolbarTextRect = toolbarTextRange.getBoundingClientRect();
flyLabelPromise = calendarTweenTo(flyLabelEl, {
left: toolbarTextRect.left - sectionRect.left,
top: toolbarTextRect.top - sectionRect.top,
fontSize: endFontSize,
color: toolbarStyle.color,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
}
}
// 7. Animate fly-rect expanding to full viewport (concurrent with label flight)
const flyRectPromise = calendarTweenTo(flyEl, {
left: 0,
top: 0,
width: wrapRect.width,
height: wrapRect.height,
duration: CALENDAR_FLY_DURATION,
ease: CALENDAR_EASE,
});
// 6. Switch view content
await Promise.all([flyRectPromise, flyLabelPromise].filter(Boolean));
// 8. Cleanup flying label
calendarFlyLabelVisible.value = false;
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
if (sourceTitleEl) sourceTitleEl.style.opacity = "";
// 9. Switch view content
apply();
await nextTick();
// 7. Hide fly-rect, fade in new content
// 10. Hide fly-rect, fade in new content
calendarFlyVisible.value = false;
resetFlyRectStyle(flyEl);
if (sceneEl) {
@@ -2920,7 +3052,7 @@ async function animateCalendarZoomIntoSource(
await calendarTweenTo(sceneEl, { opacity: 1, duration: 0.25, ease: "power2.out" });
}
// 8. Restore source + siblings
// 11. Restore source + siblings
sourceElement.style.opacity = "";
for (const child of sourceChildren) {
child.style.opacity = "";
@@ -2930,7 +3062,10 @@ async function animateCalendarZoomIntoSource(
}
} finally {
calendarFlyVisible.value = false;
calendarFlyLabelVisible.value = false;
resetFlyRectStyle(flyEl);
if (flyLabelEl) resetFlyLabelStyle(flyLabelEl);
if (toolbarLabelEl) toolbarLabelEl.style.opacity = "";
calendarZoomBusy.value = false;
}
}
@@ -4972,6 +5107,9 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
:normalized-calendar-view="normalizedCalendarView"
:calendar-fly-visible="calendarFlyVisible"
:set-calendar-fly-rect-ref="setCalendarFlyRectRef"
:calendar-fly-label-visible="calendarFlyLabelVisible"
:set-calendar-fly-label-ref="setCalendarFlyLabelRef"
:set-calendar-toolbar-label-ref="setCalendarToolbarLabelRef"
:on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
:calendar-view="calendarView"
:year-months="yearMonths"

View File

@@ -82,6 +82,9 @@ defineProps<{
selectedDayEvents: CalendarEvent[];
calendarFlyVisible: boolean;
setCalendarFlyRectRef: (element: HTMLDivElement | null) => void;
calendarFlyLabelVisible: boolean;
setCalendarFlyLabelRef: (element: HTMLDivElement | null) => void;
setCalendarToolbarLabelRef: (element: HTMLDivElement | null) => void;
}>();
</script>
@@ -103,7 +106,7 @@ defineProps<{
<button class="btn btn-xs" @click="setToday">Today</button>
</div>
<div class="text-center text-sm font-medium">
<div :ref="setCalendarToolbarLabelRef" class="text-center text-sm font-medium">
{{ calendarPeriodLabel }}
</div>
@@ -141,6 +144,13 @@ defineProps<{
<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"
@@ -499,6 +509,14 @@ defineProps<{
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;
@@ -612,16 +630,6 @@ defineProps<{
overflow: hidden;
}
.calendar-fly-rect .calendar-fly-label {
font-size: 14px;
font-weight: 600;
color: var(--color-base-content);
margin: 0 0 12px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-fly-rect .calendar-fly-skeleton {
display: flex;
flex-direction: column;