refactor(calendar): simplify UnifiedBar and extract ViewCarousel gestures

UnifiedBar (633→559 LOC):
- Remove 3 duplicate DateStrip blocks (4→1) and 1 duplicate TagStrip block (2→1)
- Remove deprecated createEventDispatcher (Svelte 4→5)
- Remove unused component imports (DateStrip, TagStrip, CalendarToolbar, DateStripFab)

ViewCarousel (402→162 LOC, -60%):
- Extract all gesture handling (touch, wheel, velocity, snap, animation,
  chain navigation) into useSwipeNavigation composable (260 LOC)
- ViewCarousel now only handles layout, date calculation, and view rendering
- Composable is reusable for any carousel/swipe navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 21:04:25 +01:00
parent e57b64590d
commit 9df908d838
4 changed files with 292 additions and 338 deletions

View file

@ -1,16 +1,13 @@
<script lang="ts">
import { setContext, onMount } from 'svelte';
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
import { createEventDispatcher } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly, slide } from 'svelte/transition';
// Components
// import QuickInputBar from '@manacore/shared-ui/components/QuickInputBar.svelte';
import DateStrip from './DateStrip.svelte';
import TagStrip from './TagStrip.svelte';
import CalendarToolbar from './CalendarToolbar.svelte';
import DateStripFab from './DateStripFab.svelte';
// Components (not yet integrated — using placeholders)
// import DateStrip from './DateStrip.svelte';
// import TagStrip from './TagStrip.svelte';
// import CalendarToolbar from './CalendarToolbar.svelte';
// Props
interface Props {
@ -68,8 +65,6 @@
onToolbarCollapsedChange = () => {},
}: Props = $props();
const dispatch = createEventDispatcher();
// Local state for transitions
let isTransitioning = $state(false);
let previousMode = $state(unifiedBarStore.mode);
@ -115,13 +110,9 @@
// Overlay menu handlers
function handleOverlayToggle() {
unifiedBarStore.toggleOverlay();
dispatch('overlayToggle', { isOpen: unifiedBarStore.isOverlayOpen });
}
function handleOverlayAction(action: string) {
dispatch('overlayAction', { action });
// Handle common actions
switch (action) {
case 'toggle-date-strip':
unifiedBarStore.toggleDateStrip();
@ -218,71 +209,6 @@
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}
<!-- Layer 1: DateStrip -->
{#if unifiedBarStore.showDateStrip}
<div
class="unified-bar-layer date-strip-layer"
style="z-index: {layerZIndices.date}; bottom: 70px;"
class:active={unifiedBarStore.activeLayer === 'date'}
transition:fly={{ ...flyConfig, y: 30 }}
role="toolbar"
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}
<!-- Layer 2: TagStrip -->
{#if unifiedBarStore.showTagStrip}
<div
class="unified-bar-layer tag-strip-layer"
style="z-index: {layerZIndices.tag}; bottom: {layerBottomOffsets.tag};"
class:active={unifiedBarStore.activeLayer === 'tag'}
transition:fly={{ ...flyConfig, y: 50 }}
role="toolbar"
aria-label="Tag filter bar"
onclick={() => handleLayerClick('tag')}
>
<!-- TagStrip placeholder -->
<div class="tag-strip-placeholder">TagStrip Component</div>
</div>
{/if}
<!-- Layer 1: DateStrip -->
{#if unifiedBarStore.showDateStrip}
<div
class="unified-bar-layer date-strip-layer"
style="z-index: {layerZIndices.date}; bottom: {layerBottomOffsets.date};"
class:active={unifiedBarStore.activeLayer === 'date'}
transition:fly={{ ...flyConfig, y: 30 }}
role="toolbar"
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}
<!-- Layer 1: DateStrip -->
{#if unifiedBarStore.showDateStrip}
<div
class="unified-bar-layer date-strip-layer"
style="z-index: {layerZIndices.date}; bottom: {layerBottomOffsets.date};"
class:active={unifiedBarStore.activeLayer === 'date'}
transition:fly={{ ...flyConfig, y: 30 }}
role="toolbar"
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}

View file

@ -2,9 +2,9 @@
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { getOffsetDate } from '$lib/utils/dateNavigation';
import { HOUR_HEIGHT_PX } from '$lib/utils/calendarConstants';
import { useSwipeNavigation } from '$lib/composables/useSwipeNavigation.svelte';
import WeekView from './WeekView.svelte';
import MonthView from './MonthView.svelte';
import AgendaView from './AgendaView.svelte';
@ -18,41 +18,23 @@
let { onQuickCreate, onEventClick, disableSwipe = false }: Props = $props();
// Swipe tracking state
let offsetX = $state(0);
let startX = $state(0);
let isSwiping = $state(false);
let isAnimating = $state(false);
let animatingDirection: 'prev' | 'next' | null = null;
// Velocity tracking for momentum
let lastX = 0;
let lastTime = 0;
let velocity = 0;
// Animation frame tracking
let animationFrameId: number | null = null;
let pendingCallback: (() => void) | null = null;
// Container refs
let viewportEl: HTMLDivElement;
let currentPageEl: HTMLDivElement;
let viewportWidth = $state(0);
// Threshold: 15% of viewport width or high velocity triggers navigation
const SNAP_THRESHOLD = 0.15;
const VELOCITY_THRESHOLD = 0.5; // px/ms - increased for faster swipes
// Animation speed (px/ms) - constant speed for linear feel
const ANIMATION_SPEED = 3.0; // increased for snappier feel
// Debounce for wheel events
const WHEEL_DEBOUNCE_MS = 50; // reduced for faster response
let wheelDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Calculate dates for previous/current/next views
let prevDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, -1));
let currentDate = $derived(viewStore.currentDate);
let nextDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, 1));
// Swipe navigation composable
const swipe = useSwipeNavigation(() => ({
getViewportWidth: () => viewportWidth,
onNavigatePrev: () => viewStore.goToPrevious(),
onNavigateNext: () => viewStore.goToNext(),
disabled: disableSwipe,
}));
// Update viewport width on mount and resize
$effect(() => {
if (!browser || !viewportEl) return;
@ -62,246 +44,24 @@
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(viewportEl);
return () => resizeObserver.disconnect();
});
// Wheel handler (trackpad horizontal scroll)
function handleWheel(e: WheelEvent) {
if (disableSwipe) return;
// Only handle horizontal scrolling (deltaX dominant)
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
e.preventDefault();
// If animating, check if we should chain navigation
if (isAnimating) {
const scrollDirection = e.deltaX < 0 ? 'next' : 'prev';
if (scrollDirection === animatingDirection && Math.abs(e.deltaX) > 10) {
// Chain navigation - immediately go to next page in same direction
chainNavigation(scrollDirection);
}
return;
}
// Simple direct offset update
offsetX += e.deltaX * -1;
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
// Debounced snap
if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = setTimeout(snapToPage, WHEEL_DEBOUNCE_MS);
}
// Touch handlers
function handleTouchStart(e: TouchEvent) {
if (disableSwipe || isAnimating) return;
// Don't interfere with event dragging
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
startX = e.touches[0].clientX;
lastX = startX;
lastTime = performance.now();
velocity = 0;
isSwiping = true;
if (wheelDebounceTimer) {
clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = null;
}
}
function handleTouchMove(e: TouchEvent) {
if (!isSwiping || disableSwipe) return;
const currentX = e.touches[0].clientX;
const currentTime = performance.now();
// Calculate velocity (px/ms)
const dt = currentTime - lastTime;
if (dt > 0) {
velocity = (currentX - lastX) / dt;
}
lastX = currentX;
lastTime = currentTime;
offsetX = currentX - startX;
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
}
function handleTouchEnd() {
if (!isSwiping) return;
isSwiping = false;
snapToPage();
}
function handleTouchCancel() {
if (!isSwiping) return;
isSwiping = false;
isAnimating = true;
animateToOffset(0, () => {
isAnimating = false;
});
}
// Chain navigation - immediately complete current and start next
function chainNavigation(direction: 'prev' | 'next') {
// Cancel current animation
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
// Complete current navigation immediately (without resetting state flags)
if (animatingDirection === 'prev') {
viewStore.goToPrevious();
} else if (animatingDirection === 'next') {
viewStore.goToNext();
}
// Reset and start new animation for another page in same direction
offsetX = direction === 'prev' ? viewportWidth * 0.4 : -viewportWidth * 0.4;
animatingDirection = direction;
const targetOffset = direction === 'prev' ? viewportWidth : -viewportWidth;
pendingCallback = () => {
if (direction === 'prev') {
viewStore.goToPrevious();
} else {
viewStore.goToNext();
}
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(targetOffset, pendingCallback);
}
// Snap to page based on current offset and velocity
function snapToPage() {
if (isAnimating || viewportWidth === 0) return;
const threshold = viewportWidth * SNAP_THRESHOLD;
const hasHighVelocity = Math.abs(velocity) > VELOCITY_THRESHOLD;
// Determine direction based on position and velocity
let targetPage: 'prev' | 'next' | 'current' = 'current';
if (offsetX > threshold || (hasHighVelocity && velocity > 0 && offsetX > 0)) {
targetPage = 'prev';
} else if (offsetX < -threshold || (hasHighVelocity && velocity < 0 && offsetX < 0)) {
targetPage = 'next';
}
isAnimating = true;
animatingDirection = targetPage === 'current' ? null : targetPage;
if (targetPage === 'prev') {
pendingCallback = () => {
viewStore.goToPrevious();
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(viewportWidth, pendingCallback);
} else if (targetPage === 'next') {
pendingCallback = () => {
viewStore.goToNext();
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(-viewportWidth, pendingCallback);
} else {
pendingCallback = () => {
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(0, pendingCallback);
}
}
function animateToOffset(targetX: number, onComplete: () => void) {
// Cancel any existing animation
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
const startX = offsetX;
const distance = targetX - startX;
const direction = Math.sign(distance);
const absDistance = Math.abs(distance);
// If already at target, complete immediately
if (absDistance < 1) {
offsetX = targetX;
onComplete();
return;
}
let lastFrameTime = performance.now();
function tick() {
const now = performance.now();
const dt = now - lastFrameTime;
lastFrameTime = now;
// Move at constant speed
const step = ANIMATION_SPEED * dt * direction;
offsetX += step;
// Check if we've reached or passed the target
const reachedTarget =
(direction > 0 && offsetX >= targetX) || (direction < 0 && offsetX <= targetX);
if (reachedTarget) {
offsetX = targetX;
animationFrameId = null;
onComplete();
} else {
animationFrameId = requestAnimationFrame(tick);
}
}
animationFrameId = requestAnimationFrame(tick);
}
// Computed styles
let trackStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`);
let trackStyle = $derived(`transform: translateX(calc(-33.333% + ${swipe.offsetX}px))`);
// Scroll to center of day (around 12:00) on initial mount
// Only for time-grid views (day, week, multi-day)
// Scroll to center of day on initial mount
onMount(() => {
if (!browser) return;
// Small delay to ensure views are rendered
setTimeout(() => {
if (!currentPageEl) return;
if (!['week'].includes(viewStore.viewType)) return;
// Only scroll for time-grid views (not month, agenda)
const timeGridViews = ['week'];
if (!timeGridViews.includes(viewStore.viewType)) return;
// Calculate scroll position to center around 12:00 (noon)
const targetHour = 12;
const targetScrollTop = targetHour * HOUR_HEIGHT_PX - currentPageEl.clientHeight / 2;
const targetScrollTop = 12 * HOUR_HEIGHT_PX - currentPageEl.clientHeight / 2;
currentPageEl.scrollTop = Math.max(0, targetScrollTop);
}, 150);
});
@ -311,15 +71,15 @@
<div
class="carousel-viewport"
bind:this={viewportEl}
onwheel={handleWheel}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
ontouchcancel={handleTouchCancel}
onwheel={swipe.handleWheel}
ontouchstart={swipe.handleTouchStart}
ontouchmove={swipe.handleTouchMove}
ontouchend={swipe.handleTouchEnd}
ontouchcancel={swipe.handleTouchCancel}
>
<div class="carousel-track" style={trackStyle}>
<!-- Previous View -->
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
<div class="carousel-page" class:inactive={!swipe.isSwiping && swipe.offsetX <= 0}>
{#if viewStore.viewType === 'week'}
<WeekView date={prevDate} />
{:else if viewStore.viewType === 'month'}
@ -343,7 +103,7 @@
</div>
<!-- Next View -->
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
<div class="carousel-page" class:inactive={!swipe.isSwiping && swipe.offsetX >= 0}>
{#if viewStore.viewType === 'week'}
<WeekView date={nextDate} />
{:else if viewStore.viewType === 'month'}

View file

@ -26,5 +26,8 @@ export { useDragToCreate, type DragToCreateConfig } from './useDragToCreate.svel
// Keyboard handling
export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte';
// Swipe navigation (carousel gesture handling)
export { useSwipeNavigation, type SwipeNavigationConfig } from './useSwipeNavigation.svelte';
// Birthday popover management
export { useBirthdayPopover } from './useBirthdayPopover.svelte';

View file

@ -0,0 +1,265 @@
/**
* Swipe Navigation Composable
* Extracts touch/wheel/velocity/snap/animation logic from ViewCarousel
*/
export interface SwipeNavigationConfig {
/** Get current viewport width */
getViewportWidth: () => number;
/** Navigate to previous page */
onNavigatePrev: () => void;
/** Navigate to next page */
onNavigateNext: () => void;
/** Whether swipe is disabled */
disabled?: boolean;
/** Snap threshold as fraction of viewport width (default: 0.15) */
snapThreshold?: number;
/** Velocity threshold in px/ms (default: 0.5) */
velocityThreshold?: number;
/** Animation speed in px/ms (default: 3.0) */
animationSpeed?: number;
/** Wheel debounce in ms (default: 50) */
wheelDebounceMs?: number;
}
export function useSwipeNavigation(getConfig: () => SwipeNavigationConfig) {
// Swipe tracking state
let offsetX = $state(0);
let startX = $state(0);
let isSwiping = $state(false);
let isAnimating = $state(false);
let animatingDirection: 'prev' | 'next' | null = null;
// Velocity tracking
let lastX = 0;
let lastTime = 0;
let velocity = 0;
// Animation frame tracking
let animationFrameId: number | null = null;
let pendingCallback: (() => void) | null = null;
// Wheel debounce
let wheelDebounceTimer: ReturnType<typeof setTimeout> | null = null;
function getDefaults() {
const config = getConfig();
return {
snapThreshold: config.snapThreshold ?? 0.15,
velocityThreshold: config.velocityThreshold ?? 0.5,
animationSpeed: config.animationSpeed ?? 3.0,
wheelDebounceMs: config.wheelDebounceMs ?? 50,
};
}
function handleWheel(e: WheelEvent) {
const config = getConfig();
if (config.disabled) return;
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
e.preventDefault();
const viewportWidth = config.getViewportWidth();
if (isAnimating) {
const scrollDirection = e.deltaX < 0 ? 'next' : 'prev';
if (scrollDirection === animatingDirection && Math.abs(e.deltaX) > 10) {
chainNavigation(scrollDirection);
}
return;
}
offsetX += e.deltaX * -1;
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
const { wheelDebounceMs } = getDefaults();
if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = setTimeout(snapToPage, wheelDebounceMs);
}
function handleTouchStart(e: TouchEvent) {
const config = getConfig();
if (config.disabled || isAnimating) return;
const target = e.target as HTMLElement;
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
startX = e.touches[0].clientX;
lastX = startX;
lastTime = performance.now();
velocity = 0;
isSwiping = true;
if (wheelDebounceTimer) {
clearTimeout(wheelDebounceTimer);
wheelDebounceTimer = null;
}
}
function handleTouchMove(e: TouchEvent) {
const config = getConfig();
if (!isSwiping || config.disabled) return;
const currentX = e.touches[0].clientX;
const currentTime = performance.now();
const dt = currentTime - lastTime;
if (dt > 0) velocity = (currentX - lastX) / dt;
lastX = currentX;
lastTime = currentTime;
const viewportWidth = config.getViewportWidth();
offsetX = currentX - startX;
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
}
function handleTouchEnd() {
if (!isSwiping) return;
isSwiping = false;
snapToPage();
}
function handleTouchCancel() {
if (!isSwiping) return;
isSwiping = false;
isAnimating = true;
animateToOffset(0, () => {
isAnimating = false;
});
}
function chainNavigation(direction: 'prev' | 'next') {
const config = getConfig();
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (animatingDirection === 'prev') config.onNavigatePrev();
else if (animatingDirection === 'next') config.onNavigateNext();
const viewportWidth = config.getViewportWidth();
offsetX = direction === 'prev' ? viewportWidth * 0.4 : -viewportWidth * 0.4;
animatingDirection = direction;
const targetOffset = direction === 'prev' ? viewportWidth : -viewportWidth;
pendingCallback = () => {
if (direction === 'prev') config.onNavigatePrev();
else config.onNavigateNext();
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(targetOffset, pendingCallback);
}
function snapToPage() {
const config = getConfig();
const viewportWidth = config.getViewportWidth();
if (isAnimating || viewportWidth === 0) return;
const { snapThreshold, velocityThreshold } = getDefaults();
const threshold = viewportWidth * snapThreshold;
const hasHighVelocity = Math.abs(velocity) > velocityThreshold;
let targetPage: 'prev' | 'next' | 'current' = 'current';
if (offsetX > threshold || (hasHighVelocity && velocity > 0 && offsetX > 0)) {
targetPage = 'prev';
} else if (offsetX < -threshold || (hasHighVelocity && velocity < 0 && offsetX < 0)) {
targetPage = 'next';
}
isAnimating = true;
animatingDirection = targetPage === 'current' ? null : targetPage;
if (targetPage === 'prev') {
pendingCallback = () => {
config.onNavigatePrev();
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(viewportWidth, pendingCallback);
} else if (targetPage === 'next') {
pendingCallback = () => {
config.onNavigateNext();
offsetX = 0;
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(-viewportWidth, pendingCallback);
} else {
pendingCallback = () => {
isAnimating = false;
animatingDirection = null;
pendingCallback = null;
};
animateToOffset(0, pendingCallback);
}
}
function animateToOffset(targetX: number, onComplete: () => void) {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
const { animationSpeed } = getDefaults();
const sX = offsetX;
const distance = targetX - sX;
const direction = Math.sign(distance);
if (Math.abs(distance) < 1) {
offsetX = targetX;
onComplete();
return;
}
let lastFrameTime = performance.now();
function tick() {
const now = performance.now();
const dt = now - lastFrameTime;
lastFrameTime = now;
offsetX += animationSpeed * dt * direction;
const reachedTarget =
(direction > 0 && offsetX >= targetX) || (direction < 0 && offsetX <= targetX);
if (reachedTarget) {
offsetX = targetX;
animationFrameId = null;
onComplete();
} else {
animationFrameId = requestAnimationFrame(tick);
}
}
animationFrameId = requestAnimationFrame(tick);
}
return {
get offsetX() {
return offsetX;
},
get isSwiping() {
return isSwiping;
},
get isAnimating() {
return isAnimating;
},
handleWheel,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handleTouchCancel,
};
}