feat(calendar): header continuity with week numbers + skeleton content in fly-rect
- Add ISO week numbers to the left of week rows in month view (8, 9, 10...) with spacer alignment on day-of-week headers - Inject label + skeleton placeholder lines into fly-rect during zoom animations: zoom-in shows source label (month name / "Week N" / day name) + pulsing bars zoom-out shows target context label + skeleton - Skeleton CSS uses pulse animation (0.8s alternate) for loading hint - Non-scoped style block for dynamically injected innerHTML elements - isoWeekNumber helper for ISO 8601 week calculation - Extended MonthRow type with weekNumber property Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2672,6 +2672,14 @@ function weekRowStartForDate(key: string) {
|
|||||||
return dayKey(date);
|
return dayKey(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isoWeekNumber(dateString: string): number {
|
||||||
|
const d = new Date(`${dateString}T00:00:00`);
|
||||||
|
const t = new Date(d.getTime());
|
||||||
|
t.setDate(t.getDate() + 3 - ((t.getDay() + 6) % 7));
|
||||||
|
const y = new Date(t.getFullYear(), 0, 4);
|
||||||
|
return 1 + Math.round(((t.getTime() - y.getTime()) / 86400000 - 3 + ((y.getDay() + 6) % 7)) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
function nextAnimationFrame() {
|
function nextAnimationFrame() {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
requestAnimationFrame(() => resolve());
|
requestAnimationFrame(() => resolve());
|
||||||
@@ -2695,6 +2703,48 @@ function resetFlyRectStyle(flyEl: HTMLElement) {
|
|||||||
flyEl.style.backgroundColor = "";
|
flyEl.style.backgroundColor = "";
|
||||||
flyEl.style.borderRadius = "";
|
flyEl.style.borderRadius = "";
|
||||||
flyEl.style.boxShadow = "";
|
flyEl.style.boxShadow = "";
|
||||||
|
flyEl.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSourceLabel(sourceElement: HTMLElement, viewBefore: string): string {
|
||||||
|
if (viewBefore === "year") {
|
||||||
|
const p = sourceElement.querySelector("p");
|
||||||
|
return p?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
if (viewBefore === "month" || viewBefore === "agenda") {
|
||||||
|
const wn = sourceElement.querySelector(".calendar-week-number");
|
||||||
|
return wn ? `Week ${wn.textContent?.trim()}` : "";
|
||||||
|
}
|
||||||
|
if (viewBefore === "week") {
|
||||||
|
const p = sourceElement.querySelector("p");
|
||||||
|
return p?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlyRectContent(labelText: string): string {
|
||||||
|
const skeleton = `
|
||||||
|
<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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function animateCalendarFlipTransition(
|
async function animateCalendarFlipTransition(
|
||||||
@@ -2740,6 +2790,8 @@ async function animateCalendarFlipTransition(
|
|||||||
flyEl.style.borderColor = "color-mix(in oklab, var(--color-base-300) 100%, transparent)";
|
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.backgroundColor = "color-mix(in oklab, var(--color-base-100) 100%, transparent)";
|
||||||
flyEl.style.boxShadow = "";
|
flyEl.style.boxShadow = "";
|
||||||
|
// Inject label + skeleton for zoom-out
|
||||||
|
flyEl.innerHTML = buildFlyRectContentForZoomOut(calendarView.value);
|
||||||
calendarFlyVisible.value = true;
|
calendarFlyVisible.value = true;
|
||||||
|
|
||||||
// 3. Switch to parent view
|
// 3. Switch to parent view
|
||||||
@@ -2815,18 +2867,22 @@ async function animateCalendarZoomIntoSource(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fade out siblings
|
// 1. Extract label before fading children
|
||||||
|
const labelText = extractSourceLabel(sourceElement, calendarView.value);
|
||||||
|
|
||||||
|
// 2. Fade out siblings
|
||||||
const siblings = Array.from(
|
const siblings = Array.from(
|
||||||
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable") ?? [],
|
sceneEl?.querySelectorAll<HTMLElement>(".calendar-hover-targetable") ?? [],
|
||||||
).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. Fade out source element's inner content (keep border/bg visible)
|
// 3. Fade out source element's inner content (keep border/bg visible)
|
||||||
const sourceChildren = Array.from(sourceElement.children) as HTMLElement[];
|
const sourceChildren = Array.from(sourceElement.children) as HTMLElement[];
|
||||||
await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" });
|
await calendarTweenTo(sourceChildren, { opacity: 0, duration: 0.12, ease: "power2.in" });
|
||||||
|
|
||||||
// 3. Clone source visual style to fly-rect, position at source bounds
|
// 4. Clone source visual style to fly-rect, inject label + skeleton
|
||||||
cloneElementStyleToFlyRect(sourceElement, flyEl);
|
cloneElementStyleToFlyRect(sourceElement, flyEl);
|
||||||
|
flyEl.innerHTML = buildFlyRectContent(labelText);
|
||||||
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, {
|
||||||
@@ -3067,13 +3123,15 @@ const monthCells = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const monthRows = computed(() => {
|
const monthRows = computed(() => {
|
||||||
const rows: Array<{ key: string; startKey: string; cells: typeof monthCells.value }> = [];
|
const rows: Array<{ key: string; startKey: string; weekNumber: number; cells: typeof monthCells.value }> = [];
|
||||||
for (let index = 0; index < monthCells.value.length; index += 7) {
|
for (let index = 0; index < monthCells.value.length; index += 7) {
|
||||||
const cells = monthCells.value.slice(index, index + 7);
|
const cells = monthCells.value.slice(index, index + 7);
|
||||||
if (!cells.length) continue;
|
if (!cells.length) continue;
|
||||||
|
const startKey = cells[0]?.key ?? selectedDateKey.value;
|
||||||
rows.push({
|
rows.push({
|
||||||
key: `${cells[0]?.key ?? index}-week-row`,
|
key: `${cells[0]?.key ?? index}-week-row`,
|
||||||
startKey: cells[0]?.key ?? selectedDateKey.value,
|
startKey,
|
||||||
|
weekNumber: isoWeekNumber(startKey),
|
||||||
cells,
|
cells,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type MonthCell = {
|
|||||||
type MonthRow = {
|
type MonthRow = {
|
||||||
key: string;
|
key: string;
|
||||||
startKey: string;
|
startKey: string;
|
||||||
|
weekNumber: number;
|
||||||
cells: MonthCell[];
|
cells: MonthCell[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,21 +221,24 @@ defineProps<{
|
|||||||
data-calendar-layer="month"
|
data-calendar-layer="month"
|
||||||
:class="calendarView === 'month' || calendarView === 'agenda' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
|
:class="calendarView === 'month' || calendarView === 'agenda' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
<div class="flex items-center gap-1">
|
||||||
<span>Sun</span>
|
<span class="calendar-week-number" aria-hidden="true"></span>
|
||||||
<span>Mon</span>
|
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60 flex-1">
|
||||||
<span>Tue</span>
|
<span>Sun</span>
|
||||||
<span>Wed</span>
|
<span>Mon</span>
|
||||||
<span>Thu</span>
|
<span>Tue</span>
|
||||||
<span>Fri</span>
|
<span>Wed</span>
|
||||||
<span>Sat</span>
|
<span>Thu</span>
|
||||||
|
<span>Fri</span>
|
||||||
|
<span>Sat</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-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 flex-1 calendar-hover-targetable"
|
class="group relative flex-1 flex items-stretch gap-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,7 +247,8 @@ 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 h-full">
|
<span class="calendar-week-number">{{ row.weekNumber }}</span>
|
||||||
|
<div class="grid grid-cols-7 gap-1 h-full flex-1">
|
||||||
<button
|
<button
|
||||||
v-for="cell in row.cells"
|
v-for="cell in row.cells"
|
||||||
:key="cell.key"
|
:key="cell.key"
|
||||||
@@ -440,6 +445,18 @@ defineProps<{
|
|||||||
right: 4px;
|
right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.calendar-hover-targetable {
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
|
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
|
||||||
@@ -563,3 +580,44 @@ defineProps<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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-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;
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user