From 542a6874d026289320bc0544e80f37fd24576004 Mon Sep 17 00:00:00 2001 From: Janus Knudsen Date: Mon, 1 Sep 2025 23:37:47 +0200 Subject: [PATCH] Improves all-day event rendering and animation Refactors all-day event handling to enhance user experience. Introduces dynamic height animation for all-day event rows, adapting to the number of overlapping events. This ensures efficient use of screen space and prevents unnecessary scrolling. Additionally, events now store all relevant data, and the header height is checked and animated after navigation. The previous HeaderRenderer.ts file has been refactored. --- src/renderers/EventRenderer.ts | 46 ++++++++++++-- src/renderers/HeaderRenderer.ts | 105 ++++++++++++++++++++++++-------- tsconfig.json | 2 +- 3 files changed, 121 insertions(+), 32 deletions(-) diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index 359f617..1f907d7 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -5,6 +5,7 @@ import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; import { CalendarConfig } from '../core/CalendarConfig'; import { DateCalculator } from '../utils/DateCalculator'; import { eventBus } from '../core/EventBus'; +import { CoreEvents } from '../constants/CoreEvents'; /** * Interface for event rendering strategies @@ -63,6 +64,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail; this.handleConvertToAllDay(eventId, targetDate, headerRenderer); }); + + // Handle navigation period change (when slide animation completes) + eventBus.on(CoreEvents.PERIOD_CHANGED, () => { + // Animate all-day height after navigation completes + import('./HeaderRenderer').then(({ DateHeaderRenderer }) => { + const headerRenderer = new DateHeaderRenderer(); + headerRenderer.checkAndAnimateAllDayHeight(); + }); + }); } /** @@ -278,10 +288,14 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { const allDayContainer = calendarHeader.querySelector('swp-allday-container'); if (!allDayContainer) return; - // Extract title + // Extract all original event data const titleElement = clone.querySelector('swp-event-title'); const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled'; + const timeElement = clone.querySelector('swp-event-time'); + const eventTime = timeElement ? timeElement.textContent || '' : ''; + const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : ''; + // Calculate column index const dayHeaders = document.querySelectorAll('swp-day-header'); let columnIndex = 1; @@ -291,15 +305,19 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { } }); - // Create all-day event + // Create all-day event with standardized data attributes const allDayEvent = document.createElement('swp-allday-event'); allDayEvent.dataset.eventId = clone.dataset.eventId || ''; + allDayEvent.dataset.title = eventTitle; + allDayEvent.dataset.start = `${targetDate}T${eventTime.split(' - ')[0]}:00`; + allDayEvent.dataset.end = `${targetDate}T${eventTime.split(' - ')[1]}:00`; allDayEvent.dataset.type = clone.dataset.type || 'work'; + allDayEvent.dataset.duration = eventDuration; allDayEvent.textContent = eventTitle; // Position in grid (allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString(); - (allDayEvent as HTMLElement).style.gridRow = '1'; + // grid-row will be set by checkAndAnimateAllDayHeight() based on actual position // Remove original clone if (clone.parentElement) { @@ -311,8 +329,16 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // Update reference this.draggedClone = allDayEvent; + + // Check if height animation is needed + import('./HeaderRenderer').then(({ DateHeaderRenderer }) => { + const headerRenderer = new DateHeaderRenderer(); + headerRenderer.checkAndAnimateAllDayHeight(); + }); } + + /** * Fade out and remove element */ @@ -487,8 +513,14 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // Create the all-day event element const allDayEvent = document.createElement('swp-allday-event'); allDayEvent.textContent = event.title; - allDayEvent.setAttribute('data-event-id', event.id); - allDayEvent.setAttribute('data-type', event.type || 'work'); + + // Set data attributes directly from CalendarEvent + allDayEvent.dataset.eventId = event.id; + allDayEvent.dataset.title = event.title; + allDayEvent.dataset.start = event.start; + allDayEvent.dataset.end = event.end; + allDayEvent.dataset.type = event.type; + allDayEvent.dataset.duration = event.metadata?.duration?.toString() || '60'; // Set grid position (column and row) (allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1 @@ -510,7 +542,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { protected renderEvent(event: CalendarEvent, container: Element, config: CalendarConfig): void { const eventElement = document.createElement('swp-event'); eventElement.dataset.eventId = event.id; + eventElement.dataset.title = event.title; + eventElement.dataset.start = event.start; + eventElement.dataset.end = event.end; eventElement.dataset.type = event.type; + eventElement.dataset.duration = event.metadata?.duration?.toString() || '60'; // Calculate position based on time const position = this.calculateEventPosition(event, config); diff --git a/src/renderers/HeaderRenderer.ts b/src/renderers/HeaderRenderer.ts index 74c82d6..24e102a 100644 --- a/src/renderers/HeaderRenderer.ts +++ b/src/renderers/HeaderRenderer.ts @@ -12,6 +12,7 @@ export interface HeaderRenderer { render(calendarHeader: HTMLElement, context: HeaderRenderContext): void; addToAllDay(dayHeader: HTMLElement): void; ensureAllDayContainers(calendarHeader: HTMLElement): void; + checkAndAnimateAllDayHeight(): void; } /** @@ -33,7 +34,7 @@ export abstract class BaseHeaderRenderer implements HeaderRenderer { if (calendarHeader) { // Ensure container exists BEFORE animation this.createAllDayMainStructure(calendarHeader); - this.animateHeaderExpansion(calendarHeader); + this.checkAndAnimateAllDayHeight(); } } } @@ -45,30 +46,82 @@ export abstract class BaseHeaderRenderer implements HeaderRenderer { this.createAllDayMainStructure(calendarHeader); } - private animateHeaderExpansion(calendarHeader: HTMLElement): void { - const root = document.documentElement; - const currentHeaderHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')); - const targetHeight = currentHeaderHeight + ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT; + checkAndAnimateAllDayHeight(): void { + const container = document.querySelector('swp-allday-container'); + if (!container) return; - // Find header spacer - const headerSpacer = document.querySelector('swp-header-spacer') as HTMLElement; + const allDayEvents = container.querySelectorAll('swp-allday-event'); - // Find or create all-day container (it should exist but be hidden) - let allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement; - if (!allDayContainer) { - // Create container if it doesn't exist - allDayContainer = document.createElement('swp-allday-container'); - calendarHeader.appendChild(allDayContainer); + // Calculate required rows - 0 if no events (will collapse) + let maxRows = 0; + + if (allDayEvents.length > 0) { + // Expand events to all dates they span and group by date + const expandedEventsByDate: Record = {}; + + (Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => { + const startISO = event.dataset.start || ''; + const endISO = event.dataset.end || startISO; + const eventId = event.dataset.eventId || ''; + + // Extract dates from ISO strings + const startDate = startISO.split('T')[0]; // YYYY-MM-DD + const endDate = endISO.split('T')[0]; // YYYY-MM-DD + + // Loop through all dates from start to end + let current = new Date(startDate); + const end = new Date(endDate); + + while (current <= end) { + const dateStr = current.toISOString().split('T')[0]; // YYYY-MM-DD format + + if (!expandedEventsByDate[dateStr]) { + expandedEventsByDate[dateStr] = []; + } + expandedEventsByDate[dateStr].push(eventId); + + // Move to next day + current.setDate(current.getDate() + 1); + } + }); + + // Find max rows needed + maxRows = Math.max( + ...Object.values(expandedEventsByDate).map(ids => ids?.length || 0), + 0 + ); } - // Animate container and spacer - CSS Grid auto row will handle header expansion + // Animate to required rows (0 = collapse, >0 = expand) + this.animateToRows(maxRows); + } + + /** + * Animate all-day container to specific number of rows + */ + animateToRows(targetRows: number): void { + const root = document.documentElement; + const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT; + const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0'); + + if (targetHeight === currentHeight) return; // No animation needed + + console.log(`🎬 All-day height animation starting: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`); + + // Find elements to animate + const calendarHeader = document.querySelector('swp-calendar-header') as HTMLElement; + const headerSpacer = document.querySelector('swp-header-spacer') as HTMLElement; + const allDayContainer = calendarHeader?.querySelector('swp-allday-container') as HTMLElement; + + if (!calendarHeader || !allDayContainer) return; + const animations = [ - // Container visibility and height animation + // Container height animation allDayContainer.animate([ - { height: '0px', opacity: '0' }, - { height: `${ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT}px`, opacity: '1' } + { height: `${currentHeight}px`, opacity: currentHeight > 0 ? '1' : '0' }, + { height: `${targetHeight}px`, opacity: '1' } ], { - duration: 150, + duration: 1000, easing: 'ease-out', fill: 'forwards' }) @@ -76,24 +129,24 @@ export abstract class BaseHeaderRenderer implements HeaderRenderer { // Add spacer animation if spacer exists if (headerSpacer) { + const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight; + const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight; + animations.push( headerSpacer.animate([ - { height: `${currentHeaderHeight}px` }, - { height: `${targetHeight}px` } + { height: `${currentSpacerHeight}px` }, + { height: `${targetSpacerHeight}px` } ], { - duration: 150, + duration: 1000, easing: 'ease-out', fill: 'forwards' }) ); } - // Wait for all animations to finish + // Update CSS variable after animation Promise.all(animations.map(anim => anim.finished)).then(() => { - // Set the CSS variable after animation - root.style.setProperty('--all-day-row-height', `${ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT}px`); - - // Notify ScrollManager about header height change + root.style.setProperty('--all-day-row-height', `${targetHeight}px`); eventBus.emit('header:height-changed'); }); } diff --git a/tsconfig.json b/tsconfig.json index d235242..acb5270 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "rootDir": "./src", "sourceMap": true, "inlineSourceMap": false, - "lib": ["ES2020", "DOM", "DOM.Iterable"] + "lib": ["ES2024", "DOM", "DOM.Iterable"] }, "include": [ "src/**/*"