mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat(calendar): add swipe navigation for calendar views
Implement horizontal swipe/trackpad navigation between calendar periods: - Add ViewCarousel component with animated page transitions - Support touch swipe, trackpad scroll, and wheel navigation - Create useSwipeNavigation composable for reusable swipe handling - Add dateNavigation utility for calculating view-specific date offsets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e7113982e
commit
e0d7b3d13d
11 changed files with 618 additions and 65 deletions
|
|
@ -10,10 +10,15 @@
|
|||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onEventClick }: Props = $props();
|
||||
let { date, onEventClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Group events by date
|
||||
let groupedEvents = $derived.by(() => {
|
||||
|
|
@ -24,7 +29,7 @@
|
|||
const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id));
|
||||
|
||||
// Filter events that start from current date onwards
|
||||
const startDate = startOfDay(viewStore.currentDate);
|
||||
const startDate = startOfDay(effectiveDate);
|
||||
|
||||
const groups: Map<string, CalendarEvent[]> = new Map();
|
||||
|
||||
|
|
|
|||
|
|
@ -27,12 +27,17 @@
|
|||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Use shared constants
|
||||
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
|
||||
|
|
@ -55,7 +60,7 @@
|
|||
// Get timed events, filtering out those outside visible range when hour filter is enabled
|
||||
let timedEvents = $derived(
|
||||
getVisibleTimedEvents(
|
||||
eventsStore.getEventsForDay(viewStore.currentDate),
|
||||
eventsStore.getEventsForDay(effectiveDate),
|
||||
calendarsStore.visibleCalendars,
|
||||
{
|
||||
filterHoursEnabled: settingsStore.filterHoursEnabled,
|
||||
|
|
@ -71,7 +76,7 @@
|
|||
return { before: [], after: [] };
|
||||
}
|
||||
return getVisibleOverflowEvents(
|
||||
eventsStore.getEventsForDay(viewStore.currentDate),
|
||||
eventsStore.getEventsForDay(effectiveDate),
|
||||
calendarsStore.visibleCalendars,
|
||||
settingsStore.dayStartHour,
|
||||
settingsStore.dayEndHour
|
||||
|
|
@ -80,7 +85,7 @@
|
|||
|
||||
let allDayEvents = $derived(
|
||||
getVisibleAllDayEvents(
|
||||
eventsStore.getEventsForDay(viewStore.currentDate),
|
||||
eventsStore.getEventsForDay(effectiveDate),
|
||||
calendarsStore.visibleCalendars
|
||||
)
|
||||
);
|
||||
|
|
@ -106,7 +111,7 @@
|
|||
// Get birthdays for current day (if enabled in settings)
|
||||
let birthdays = $derived.by(() => {
|
||||
if (!settingsStore.showBirthdays) return [];
|
||||
return birthdaysStore.getBirthdaysForDay(viewStore.currentDate);
|
||||
return birthdaysStore.getBirthdaysForDay(effectiveDate);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -225,7 +230,7 @@
|
|||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Create new start time on same day
|
||||
let newStart = new Date(viewStore.currentDate);
|
||||
let newStart = new Date(effectiveDate);
|
||||
newStart = setHours(newStart, Math.floor(clampedMinutes / 60));
|
||||
newStart = setMinutes(newStart, clampedMinutes % 60);
|
||||
newStart.setSeconds(0, 0);
|
||||
|
|
@ -327,7 +332,7 @@
|
|||
firstVisibleHour * 60,
|
||||
Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES)
|
||||
);
|
||||
newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60));
|
||||
newStart = setHours(new Date(effectiveDate), Math.floor(newStartMinutes / 60));
|
||||
newStart = setMinutes(newStart, newStartMinutes % 60);
|
||||
newStart.setSeconds(0, 0);
|
||||
} else {
|
||||
|
|
@ -335,7 +340,7 @@
|
|||
lastVisibleHour * 60,
|
||||
Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES)
|
||||
);
|
||||
newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60));
|
||||
newEnd = setHours(new Date(effectiveDate), Math.floor(newEndMinutes / 60));
|
||||
newEnd = setMinutes(newEnd, newEndMinutes % 60);
|
||||
newEnd.setSeconds(0, 0);
|
||||
}
|
||||
|
|
@ -586,7 +591,7 @@
|
|||
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
await todosStore.updateTodo(data.taskId, {
|
||||
scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'),
|
||||
scheduledDate: format(effectiveDate, 'yyyy-MM-dd'),
|
||||
scheduledStartTime: startTime,
|
||||
scheduledEndTime: endTime,
|
||||
estimatedDuration: duration,
|
||||
|
|
@ -659,7 +664,7 @@
|
|||
* Get scheduled tasks for current day
|
||||
*/
|
||||
function getScheduledTasks(): Task[] {
|
||||
return todosStore.getScheduledTasksForDay(viewStore.currentDate);
|
||||
return todosStore.getScheduledTasksForDay(effectiveDate);
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
|
|
@ -683,7 +688,7 @@
|
|||
// Don't create event if dragging or resizing
|
||||
if (isDragging || isResizing) return;
|
||||
|
||||
const startTime = new Date(viewStore.currentDate);
|
||||
const startTime = new Date(effectiveDate);
|
||||
startTime.setHours(hour, 0, 0, 0);
|
||||
|
||||
if (onQuickCreate) {
|
||||
|
|
@ -756,7 +761,7 @@
|
|||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day-column"
|
||||
class:today={isToday(viewStore.currentDate)}
|
||||
class:today={isToday(effectiveDate)}
|
||||
class:drop-target={isSidebarDropTarget}
|
||||
bind:this={dayColumnRef}
|
||||
ondragover={handleSidebarDragOver}
|
||||
|
|
@ -855,7 +860,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(viewStore.currentDate)}
|
||||
{#if isToday(effectiveDate)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,16 +36,21 @@
|
|||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Get all days to display in the month grid (including days from prev/next months)
|
||||
let allCalendarDays = $derived.by(() => {
|
||||
const monthStart = startOfMonth(viewStore.currentDate);
|
||||
const monthEnd = endOfMonth(viewStore.currentDate);
|
||||
const monthStart = startOfMonth(effectiveDate);
|
||||
const monthEnd = endOfMonth(effectiveDate);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: settingsStore.weekStartsOn });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: settingsStore.weekStartsOn });
|
||||
|
||||
|
|
@ -85,7 +90,6 @@
|
|||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let monthViewRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// Store for day cell refs
|
||||
let dayCellRefs = $state<Map<string, HTMLElement>>(new Map());
|
||||
|
|
@ -276,7 +280,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}>
|
||||
<div class="month-view" style="--column-count: {columnCount}">
|
||||
<!-- Week day headers -->
|
||||
<div class="weekday-headers">
|
||||
{#each weekDays as day}
|
||||
|
|
@ -292,7 +296,7 @@
|
|||
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
|
||||
<div
|
||||
class="day-cell"
|
||||
class:other-month={!isSameMonth(day, viewStore.currentDate)}
|
||||
class:other-month={!isSameMonth(day, effectiveDate)}
|
||||
class:today={isToday(day)}
|
||||
class:drop-target={isDropTarget}
|
||||
use:bindDayCellRef={day}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,31 @@
|
|||
// Props
|
||||
interface Props {
|
||||
dayCount: 5 | 10 | 14;
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
let { dayCount, date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Calculate view range based on effective date
|
||||
let effectiveViewRange = $derived.by(() => {
|
||||
if (date) {
|
||||
// Calculate range for the provided date based on day count
|
||||
const end = new Date(date);
|
||||
end.setDate(end.getDate() + dayCount - 1);
|
||||
return {
|
||||
start: date,
|
||||
end: end,
|
||||
};
|
||||
}
|
||||
// Use viewStore range when no date override
|
||||
return viewStore.viewRange;
|
||||
});
|
||||
|
||||
// Get date-fns locale based on current app locale
|
||||
const dateLocales = { de, en: enUS, fr, es, it };
|
||||
|
|
@ -58,8 +78,8 @@
|
|||
// Generate days based on view range, optionally filtering weekends
|
||||
let allDays = $derived(
|
||||
eachDayOfInterval({
|
||||
start: viewStore.viewRange.start,
|
||||
end: viewStore.viewRange.end,
|
||||
start: effectiveViewRange.start,
|
||||
end: effectiveViewRange.end,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { getOffsetDate } from '$lib/utils/dateNavigation';
|
||||
import WeekView from './WeekView.svelte';
|
||||
import DayView from './DayView.svelte';
|
||||
import MonthView from './MonthView.svelte';
|
||||
import MultiDayView from './MultiDayView.svelte';
|
||||
import YearView from './YearView.svelte';
|
||||
import AgendaView from './AgendaView.svelte';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
disableSwipe?: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Container refs
|
||||
let viewportEl: HTMLDivElement;
|
||||
let viewportWidth = $state(0);
|
||||
|
||||
// Threshold: 15% of viewport width triggers navigation
|
||||
const SNAP_THRESHOLD = 0.15;
|
||||
// Debounce time for wheel events
|
||||
const WHEEL_DEBOUNCE_MS = 150;
|
||||
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));
|
||||
|
||||
// Update viewport width on mount and resize
|
||||
$effect(() => {
|
||||
if (!browser || !viewportEl) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
viewportWidth = viewportEl.offsetWidth;
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(viewportEl);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
});
|
||||
|
||||
// Wheel handler (trackpad horizontal scroll)
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (disableSwipe || isAnimating) 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();
|
||||
|
||||
// Update offset (invert for natural scrolling direction)
|
||||
offsetX += e.deltaX * -1;
|
||||
|
||||
// Clamp to max 1 page in each direction
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
|
||||
// Debounced snap check
|
||||
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;
|
||||
isSwiping = true;
|
||||
|
||||
// Cancel any pending wheel snap
|
||||
if (wheelDebounceTimer) {
|
||||
clearTimeout(wheelDebounceTimer);
|
||||
wheelDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!isSwiping || disableSwipe) return;
|
||||
|
||||
const currentX = e.touches[0].clientX;
|
||||
offsetX = currentX - startX;
|
||||
|
||||
// Clamp to max 1 page in each direction
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
snapToPage();
|
||||
}
|
||||
|
||||
function handleTouchCancel() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
// Snap back to current on cancel
|
||||
animateToOffset(0, () => {});
|
||||
}
|
||||
|
||||
// Snap to page based on current offset
|
||||
function snapToPage() {
|
||||
if (isAnimating || viewportWidth === 0) return;
|
||||
|
||||
isAnimating = true;
|
||||
const threshold = viewportWidth * SNAP_THRESHOLD;
|
||||
|
||||
if (offsetX > threshold) {
|
||||
// Snap to previous
|
||||
animateToOffset(viewportWidth, () => {
|
||||
viewStore.goToPrevious();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
});
|
||||
} else if (offsetX < -threshold) {
|
||||
// Snap to next
|
||||
animateToOffset(-viewportWidth, () => {
|
||||
viewStore.goToNext();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
});
|
||||
} else {
|
||||
// Snap back to current
|
||||
animateToOffset(0, () => {
|
||||
isAnimating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function animateToOffset(targetX: number, onComplete: () => void) {
|
||||
offsetX = targetX;
|
||||
// Wait for CSS transition to complete
|
||||
setTimeout(onComplete, 300);
|
||||
}
|
||||
|
||||
// Computed transform style
|
||||
let transformStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="carousel-viewport"
|
||||
bind:this={viewportEl}
|
||||
onwheel={handleWheel}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
ontouchcancel={handleTouchCancel}
|
||||
>
|
||||
<div class="carousel-track" class:animating={isAnimating} style={transformStyle}>
|
||||
<!-- Previous View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView date={prevDate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} date={prevDate} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView date={prevDate} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} date={prevDate} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} date={prevDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView date={prevDate} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView date={prevDate} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView date={prevDate} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Current View (main interactive view) -->
|
||||
<div class="carousel-page current">
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView {onEventClick} />
|
||||
{:else}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView date={nextDate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} date={nextDate} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView date={nextDate} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} date={nextDate} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} date={nextDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView date={nextDate} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView date={nextDate} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView date={nextDate} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.carousel-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.carousel-track.animating {
|
||||
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.carousel-page {
|
||||
width: 33.333%;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Inactive pages have reduced interactivity for performance */
|
||||
.carousel-page.inactive {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel-page.current {
|
||||
/* Always interactive */
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -32,6 +32,8 @@
|
|||
setHours,
|
||||
setMinutes,
|
||||
getWeek,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
} from 'date-fns';
|
||||
import { de, enUS, fr, es, it } from 'date-fns/locale';
|
||||
import { locale, _ } from 'svelte-i18n';
|
||||
|
|
@ -39,12 +41,31 @@
|
|||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Calculate view range based on effective date
|
||||
let effectiveViewRange = $derived.by(() => {
|
||||
if (date) {
|
||||
// Calculate range for the provided date
|
||||
const weekStartsOn = settingsStore.weekStartsOn;
|
||||
return {
|
||||
start: startOfWeek(date, { weekStartsOn }),
|
||||
end: endOfWeek(date, { weekStartsOn }),
|
||||
};
|
||||
}
|
||||
// Use viewStore range when no date override
|
||||
return viewStore.viewRange;
|
||||
});
|
||||
|
||||
// Use shared constants
|
||||
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
|
||||
|
|
@ -59,8 +80,8 @@
|
|||
// Generate days of the week, optionally filtering weekends
|
||||
let allDays = $derived(
|
||||
eachDayOfInterval({
|
||||
start: viewStore.viewRange.start,
|
||||
end: viewStore.viewRange.end,
|
||||
start: effectiveViewRange.start,
|
||||
end: effectiveViewRange.end,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -70,7 +91,7 @@
|
|||
|
||||
// Get week number for display
|
||||
let weekNumber = $derived(
|
||||
getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
|
||||
getWeek(effectiveViewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
|
||||
);
|
||||
|
||||
// Use composables for hour filtering and time indicator
|
||||
|
|
|
|||
|
|
@ -19,14 +19,19 @@
|
|||
import type { CalendarViewType, CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Derived values
|
||||
let year = $derived(viewStore.currentDate.getFullYear());
|
||||
let year = $derived(effectiveDate.getFullYear());
|
||||
|
||||
let months = $derived(Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)));
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKey
|
|||
// Birthday popover management
|
||||
export { useBirthdayPopover } from './useBirthdayPopover.svelte';
|
||||
|
||||
// Swipe/scroll navigation for view switching
|
||||
export { useSwipeNavigation, type SwipeNavigationOptions } from './useSwipeNavigation.svelte';
|
||||
|
||||
// Legacy exports (kept for backwards compatibility, may be removed later)
|
||||
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
|
||||
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Swipe Navigation Composable
|
||||
* Enables horizontal swipe/scroll navigation for calendar views
|
||||
*
|
||||
* Supports:
|
||||
* - Trackpad horizontal scroll (Mac/Windows)
|
||||
* - Touch swipe (Mobile/Tablet)
|
||||
* - Mouse horizontal scroll wheel
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface SwipeNavigationOptions {
|
||||
/** Minimum pixels to trigger navigation (default: 80) */
|
||||
threshold?: number;
|
||||
/** Debounce time in ms for wheel events (default: 150) */
|
||||
debounceMs?: number;
|
||||
/** Disable swipe navigation temporarily */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 80;
|
||||
const DEFAULT_DEBOUNCE_MS = 150;
|
||||
|
||||
/**
|
||||
* Creates swipe/scroll navigation for a container element
|
||||
*
|
||||
* @param getElement - Function returning the target element
|
||||
* @param onNext - Callback when swiping left (go to next period)
|
||||
* @param onPrevious - Callback when swiping right (go to previous period)
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { useSwipeNavigation } from '$lib/composables';
|
||||
* import { viewStore } from '$lib/stores/view.svelte';
|
||||
*
|
||||
* let containerRef: HTMLElement;
|
||||
*
|
||||
* useSwipeNavigation(
|
||||
* () => containerRef,
|
||||
* () => viewStore.goToNext(),
|
||||
* () => viewStore.goToPrevious()
|
||||
* );
|
||||
* </script>
|
||||
*
|
||||
* <div bind:this={containerRef}>...</div>
|
||||
* ```
|
||||
*/
|
||||
export function useSwipeNavigation(
|
||||
getElement: () => HTMLElement | null,
|
||||
onNext: () => void,
|
||||
onPrevious: () => void,
|
||||
options: SwipeNavigationOptions = {}
|
||||
) {
|
||||
if (!browser) return;
|
||||
|
||||
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
||||
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
||||
|
||||
// Track accumulated wheel delta for trackpad detection
|
||||
let accumulatedDelta = 0;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Touch tracking
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let isTouching = false;
|
||||
|
||||
/**
|
||||
* Handle wheel events (trackpad horizontal scroll)
|
||||
*/
|
||||
function handleWheel(e: WheelEvent) {
|
||||
// Skip if disabled
|
||||
if (options.disabled) return;
|
||||
|
||||
// Only handle horizontal scrolling (deltaX dominant)
|
||||
// This distinguishes trackpad gestures from vertical scrolling
|
||||
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;
|
||||
|
||||
// Prevent default scroll behavior for horizontal gestures
|
||||
e.preventDefault();
|
||||
|
||||
// Accumulate horizontal delta
|
||||
accumulatedDelta += e.deltaX;
|
||||
|
||||
// Reset accumulator after debounce period
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
accumulatedDelta = 0;
|
||||
}, debounceMs);
|
||||
|
||||
// Check if threshold reached
|
||||
if (accumulatedDelta > threshold) {
|
||||
onNext();
|
||||
accumulatedDelta = 0;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
} else if (accumulatedDelta < -threshold) {
|
||||
onPrevious();
|
||||
accumulatedDelta = 0;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch start
|
||||
*/
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
// Skip if disabled
|
||||
if (options.disabled) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
isTouching = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch end
|
||||
*/
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
// Skip if disabled or wasn't tracking
|
||||
if (options.disabled || !isTouching) return;
|
||||
isTouching = false;
|
||||
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = touchEndY - touchStartY;
|
||||
|
||||
// Only trigger if horizontal movement is dominant and exceeds threshold
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) {
|
||||
if (deltaX > 0) {
|
||||
// Swiped right → go to previous
|
||||
onPrevious();
|
||||
} else {
|
||||
// Swiped left → go to next
|
||||
onNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch cancel
|
||||
*/
|
||||
function handleTouchCancel() {
|
||||
isTouching = false;
|
||||
}
|
||||
|
||||
// Setup and cleanup with $effect
|
||||
$effect(() => {
|
||||
const el = getElement();
|
||||
if (!el) return;
|
||||
|
||||
// Add event listeners
|
||||
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||
el.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
el.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
el.addEventListener('touchcancel', handleTouchCancel, { passive: true });
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
el.removeEventListener('wheel', handleWheel);
|
||||
el.removeEventListener('touchstart', handleTouchStart);
|
||||
el.removeEventListener('touchend', handleTouchEnd);
|
||||
el.removeEventListener('touchcancel', handleTouchCancel);
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
63
apps/calendar/apps/web/src/lib/utils/dateNavigation.ts
Normal file
63
apps/calendar/apps/web/src/lib/utils/dateNavigation.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Date Navigation Utilities
|
||||
* Helper functions for calculating date offsets based on view type
|
||||
*/
|
||||
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
import {
|
||||
addDays,
|
||||
addWeeks,
|
||||
addMonths,
|
||||
addYears,
|
||||
subDays,
|
||||
subWeeks,
|
||||
subMonths,
|
||||
subYears,
|
||||
} from 'date-fns';
|
||||
|
||||
/**
|
||||
* Calculate a date offset based on the current view type
|
||||
*
|
||||
* @param date - The base date
|
||||
* @param viewType - The current calendar view type
|
||||
* @param offset - Number of periods to offset (-1 = previous, 1 = next)
|
||||
* @returns The calculated date
|
||||
*
|
||||
* @example
|
||||
* // Get previous week's date
|
||||
* getOffsetDate(new Date(), 'week', -1)
|
||||
*
|
||||
* // Get next month's date
|
||||
* getOffsetDate(new Date(), 'month', 1)
|
||||
*/
|
||||
export function getOffsetDate(date: Date, viewType: CalendarViewType, offset: number): Date {
|
||||
switch (viewType) {
|
||||
case 'day':
|
||||
return offset > 0 ? addDays(date, offset) : subDays(date, Math.abs(offset));
|
||||
|
||||
case '5day':
|
||||
return offset > 0 ? addDays(date, offset * 5) : subDays(date, Math.abs(offset) * 5);
|
||||
|
||||
case 'week':
|
||||
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
|
||||
|
||||
case '10day':
|
||||
return offset > 0 ? addDays(date, offset * 10) : subDays(date, Math.abs(offset) * 10);
|
||||
|
||||
case '14day':
|
||||
return offset > 0 ? addDays(date, offset * 14) : subDays(date, Math.abs(offset) * 14);
|
||||
|
||||
case 'month':
|
||||
return offset > 0 ? addMonths(date, offset) : subMonths(date, Math.abs(offset));
|
||||
|
||||
case 'year':
|
||||
return offset > 0 ? addYears(date, offset) : subYears(date, Math.abs(offset));
|
||||
|
||||
case 'agenda':
|
||||
// Agenda moves by 7 days
|
||||
return offset > 0 ? addDays(date, offset * 7) : subDays(date, Math.abs(offset) * 7);
|
||||
|
||||
default:
|
||||
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,7 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { isSidebarMode as sidebarModeStore } from '$lib/stores/navigation';
|
||||
import WeekView from '$lib/components/calendar/WeekView.svelte';
|
||||
import DayView from '$lib/components/calendar/DayView.svelte';
|
||||
import MonthView from '$lib/components/calendar/MonthView.svelte';
|
||||
import MultiDayView from '$lib/components/calendar/MultiDayView.svelte';
|
||||
import YearView from '$lib/components/calendar/YearView.svelte';
|
||||
import AgendaView from '$lib/components/calendar/AgendaView.svelte';
|
||||
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
|
||||
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import { CalendarViewSkeleton } from '$lib/components/skeletons';
|
||||
|
|
@ -150,36 +145,8 @@
|
|||
<div class="calendar-content">
|
||||
{#if !initialized}
|
||||
<CalendarViewSkeleton />
|
||||
{:else if viewStore.viewType === 'day'}
|
||||
<DayView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView
|
||||
dayCount={5}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView
|
||||
dayCount={10}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView
|
||||
dayCount={14}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView onEventClick={handleEventClick} />
|
||||
{:else}
|
||||
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue