@nomideusz/svelte-calendar 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +192 -10
  2. package/dist/adapters/composite.d.ts +15 -0
  3. package/dist/adapters/composite.js +65 -0
  4. package/dist/adapters/index.d.ts +5 -1
  5. package/dist/adapters/index.js +2 -0
  6. package/dist/adapters/mapped.d.ts +170 -0
  7. package/dist/adapters/mapped.js +343 -0
  8. package/dist/adapters/recurring.d.ts +4 -0
  9. package/dist/adapters/recurring.js +5 -10
  10. package/dist/adapters/types.d.ts +11 -2
  11. package/dist/assets/favicon.svg +9 -1
  12. package/dist/calendar/Calendar.svelte +161 -90
  13. package/dist/calendar/Calendar.svelte.d.ts +12 -0
  14. package/dist/core/index.d.ts +3 -1
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/measure.d.ts +104 -0
  17. package/dist/core/measure.js +195 -0
  18. package/dist/core/time.js +2 -2
  19. package/dist/core/types.d.ts +32 -0
  20. package/dist/engine/event-store.svelte.js +22 -7
  21. package/dist/headless/create-agenda.svelte.d.ts +67 -0
  22. package/dist/headless/create-agenda.svelte.js +139 -0
  23. package/dist/headless/create-calendar.svelte.d.ts +2 -0
  24. package/dist/headless/create-calendar.svelte.js +380 -0
  25. package/dist/headless/index.d.ts +4 -0
  26. package/dist/headless/index.js +2 -0
  27. package/dist/headless/types.d.ts +219 -0
  28. package/dist/headless/types.js +1 -0
  29. package/dist/index.d.ts +6 -4
  30. package/dist/index.js +4 -2
  31. package/dist/primitives/DayHeader.svelte +6 -6
  32. package/dist/primitives/EmptySlot.svelte +3 -3
  33. package/dist/primitives/EventBlock.svelte +99 -14
  34. package/dist/primitives/NowIndicator.svelte +5 -5
  35. package/dist/views/agenda/AgendaDay.svelte +215 -116
  36. package/dist/views/agenda/AgendaWeek.svelte +166 -113
  37. package/dist/views/mobile/MobileDay.svelte +45 -21
  38. package/dist/views/mobile/MobileWeek.svelte +85 -35
  39. package/dist/views/planner/PlannerDay.svelte +116 -60
  40. package/dist/views/planner/PlannerWeek.svelte +228 -166
  41. package/dist/views/shared/context.svelte.d.ts +47 -0
  42. package/dist/views/shared/context.svelte.js +47 -0
  43. package/dist/views/shared/format.d.ts +19 -0
  44. package/dist/views/shared/format.js +46 -0
  45. package/dist/views/shared/index.d.ts +4 -0
  46. package/dist/views/shared/index.js +2 -0
  47. package/package.json +9 -2
@@ -9,7 +9,8 @@
9
9
  • Generous whitespace, thin dividers, minimal chrome.
10
10
  -->
11
11
  <script lang="ts">
12
- import { getContext, onMount, tick, untrack, type Snippet } from 'svelte';
12
+ import { onMount, tick, untrack } from 'svelte';
13
+ import { useCalendarContext } from '../shared/context.svelte.js';
13
14
  import { createClock } from '../../core/clock.svelte.js';
14
15
  import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
15
16
  import type { DragState } from '../../engine/drag.svelte.js';
@@ -48,58 +49,56 @@
48
49
  readOnly = false,
49
50
  }: Props = $props();
50
51
 
51
- // ── Drag support (available when inside Calendar) ──
52
- const drag = getContext<DragState>('calendar:drag') as DragState | undefined;
53
- const commitDragCtx = getContext<() => void>('calendar:commitDrag') as (() => void) | undefined;
54
- const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
55
- const loadRangeCtx = getContext<{ current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void }>('calendar:loadRange') as { current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void } | undefined;
56
-
52
+ const ctx = useCalendarContext();
57
53
  const clock = createClock();
58
- const showNavCtx = getContext<{ current: boolean }>('calendar:showNavigation') as { current: boolean } | undefined;
59
- const equalDaysCtx = getContext<{ current: boolean }>('calendar:equalDays') as { current: boolean } | undefined;
60
- const showDatesCtx = getContext<{ current: boolean }>('calendar:showDates') as { current: boolean } | undefined;
61
- const hideDaysCtx = getContext<{ current: number[] | undefined }>('calendar:hideDays') as { current: number[] | undefined } | undefined;
62
- const showNav = $derived(showNavCtx?.current ?? true);
63
- const equalDays = $derived(equalDaysCtx?.current ?? false);
64
- const showDates = $derived(showDatesCtx?.current ?? true);
65
- const hideDays = $derived(hideDaysCtx?.current);
66
-
67
- // ── New feature contexts ──
68
- const blockedSlotsCtx = getContext<{ current: BlockedSlot[] | undefined }>('calendar:blockedSlots') as { current: BlockedSlot[] | undefined } | undefined;
69
- const dayHeaderSnippetCtx = getContext<{ current: Snippet<[{ date: Date; isToday: boolean; dayName: string }]> | undefined }>('calendar:dayHeaderSnippet') as { current: Snippet<[{ date: Date; isToday: boolean; dayName: string }]> | undefined } | undefined;
70
- const minDurationCtx = getContext<{ current: number | undefined }>('calendar:minDuration') as { current: number | undefined } | undefined;
71
- const callbacksCtx = getContext<{ oneventhover?: (event: TimelineEvent) => void }>('calendar:callbacks') as { oneventhover?: (event: TimelineEvent) => void } | undefined;
72
- const disabledDatesCtx = getContext<{ current: Date[] | undefined }>('calendar:disabledDates') as { current: Date[] | undefined } | undefined;
73
- const autoHeightCtx = getContext<{ current: boolean }>('calendar:autoHeight') as { current: boolean } | undefined;
74
-
75
- const blockedSlots = $derived(blockedSlotsCtx?.current);
76
- const dayHeaderSnippet = $derived(dayHeaderSnippetCtx?.current);
77
- const minDuration = $derived(minDurationCtx?.current);
78
- const autoHeight = $derived(autoHeightCtx?.current ?? false);
79
- const oneventhover = $derived(callbacksCtx?.oneventhover);
80
- const disabledDates = $derived(disabledDatesCtx?.current);
81
- const disabledSet = $derived(new Set(disabledDates?.map(d => sod(d.getTime())) ?? []));
82
-
83
- // ─── Infinite-scroll config ────────────────────────
84
- const BUFFER_WEEKS = 6; // weeks rendered on each side of anchor
85
- const EDGE_WEEKS = 2; // rebase when this close to an edge
86
- const SHIFT_WEEKS = 3; // weeks to shift per rebase
54
+ const drag = $derived(ctx.drag);
55
+ const commitDragCtx = $derived(ctx.commitDrag);
56
+ const viewState = $derived(ctx.viewState);
57
+ const loadRangeCtx = $derived(ctx.loadRange);
58
+ const showNav = $derived(ctx.showNav);
59
+ const equalDays = $derived(ctx.equalDays);
60
+ const showDates = $derived(ctx.showDates);
61
+ const hideDays = $derived(ctx.hideDays);
62
+ const blockedSlots = $derived(ctx.blockedSlots);
63
+ const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
64
+ const minDuration = $derived(ctx.minDuration);
65
+ const autoHeight = $derived(ctx.autoHeight);
66
+ const oneventhover = $derived(ctx.oneventhover);
67
+ const disabledSet = $derived(ctx.disabledSet);
68
+
69
+ // ─── Buffer config ─────────────────────────────
70
+ const INITIAL_BUFFER = 52; // ±1 year on load
71
+ const EXTEND_BY = 26; // +6 months when user hits edge
72
+ const EDGE_PX = 200; // extend when this close to scroll edge
73
+ let bufferBefore = $state(INITIAL_BUFFER);
74
+ let bufferAfter = $state(INITIAL_BUFFER);
87
75
  const MAX_EVENTS_SHOWN = 5;
88
76
 
89
- // Internal anchor drives layout; syncs bidirectionally with focusDate.
90
77
  const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
91
78
  let internalFocusMs = $state(_initMs);
92
79
  let lastExternalMs = _initMs;
93
- let rebasing = false;
94
80
 
95
81
  let el: HTMLDivElement;
96
82
  let scrolled = $state(false);
97
83
 
98
- function scrollCurrentWeekIntoContainer(behavior: ScrollBehavior = 'auto') {
84
+ function scrollWeekIntoContainer(targetMs?: number, behavior: ScrollBehavior = 'auto') {
99
85
  if (!el) return;
100
- const current = el.querySelector<HTMLElement>('.wg-week--current');
101
- if (!current) return;
102
- const targetTop = current.offsetTop - (el.clientHeight - current.offsetHeight) / 2;
86
+ let target: HTMLElement | null = null;
87
+ if (targetMs !== undefined) {
88
+ // Find the week row containing this date
89
+ const rows = el.querySelectorAll<HTMLElement>('[data-week]');
90
+ for (const row of rows) {
91
+ const weekMs = Number(row.dataset.week);
92
+ if (weekMs <= targetMs && targetMs < weekMs + customDays * DAY_MS) {
93
+ target = row;
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ // Fall back to current week
99
+ if (!target) target = el.querySelector<HTMLElement>('.wg-week--current');
100
+ if (!target) return;
101
+ const targetTop = target.offsetTop - (el.clientHeight - target.offsetHeight) / 2;
103
102
  el.scrollTo({ top: Math.max(0, targetTop), behavior });
104
103
  }
105
104
 
@@ -117,8 +116,8 @@
117
116
  // what range we need. Calendar's single $effect handles loading.
118
117
  $effect(() => {
119
118
  if (!loadRangeCtx) return;
120
- const rangeStart = new Date(anchorPeriodStart - BUFFER_WEEKS * customDays * DAY_MS);
121
- const rangeEnd = new Date(anchorPeriodStart + (BUFFER_WEEKS + 1) * customDays * DAY_MS);
119
+ const rangeStart = new Date(anchorPeriodStart - bufferBefore * customDays * DAY_MS);
120
+ const rangeEnd = new Date(anchorPeriodStart + (bufferAfter + 1) * customDays * DAY_MS);
122
121
  loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
123
122
  return () => loadRangeCtx.set(null);
124
123
  });
@@ -147,7 +146,7 @@
147
146
  const weeks = $derived.by(() => {
148
147
  const result: WeekRow[] = [];
149
148
 
150
- for (let w = -BUFFER_WEEKS; w <= BUFFER_WEEKS; w++) {
149
+ for (let w = -bufferBefore; w <= bufferAfter; w++) {
151
150
  const periodStart = anchorPeriodStart + w * customDays * DAY_MS;
152
151
  const isCurrent = todayMs >= periodStart && todayMs < periodStart + customDays * DAY_MS;
153
152
  const days: DayCell[] = [];
@@ -223,88 +222,20 @@
223
222
  // ─── Scroll to current week on mount ────────────────
224
223
  onMount(async () => {
225
224
  await tick();
226
- scrollCurrentWeekIntoContainer();
225
+ scrollWeekIntoContainer();
227
226
  });
228
227
 
229
- // ─── Infinite-scroll helpers ────────────────────────
230
-
231
- /** Detect external focusDate changes (from nav arrows). */
228
+ // ─── External navigation (arrows, goToday) ──────────
232
229
  $effect(() => {
233
230
  const ext = focusDate ? sod(focusDate.getTime()) : clock.today;
234
- if (ext !== lastExternalMs && !rebasing) {
231
+ if (ext !== lastExternalMs) {
235
232
  lastExternalMs = ext;
236
233
  internalFocusMs = ext;
237
- tick().then(() => scrollCurrentWeekIntoContainer('smooth'));
234
+ tick().then(() => scrollWeekIntoContainer(ext, 'smooth'));
238
235
  }
239
236
  });
240
237
 
241
- /** Check if viewport is near top/bottom edge; if so, rebase the window. */
242
- function checkVerticalEdges() {
243
- if (!el || !viewState || rebasing) return;
244
- const rows = el.querySelectorAll<HTMLElement>('[data-week]');
245
- if (!rows.length) return;
246
-
247
- const avgH = el.scrollHeight / rows.length;
248
- const threshold = avgH * EDGE_WEEKS;
249
- const maxScroll = el.scrollHeight - el.clientHeight;
250
-
251
- if (el.scrollTop < threshold) {
252
- rebaseWeeks(-1);
253
- } else if (maxScroll > 0 && maxScroll - el.scrollTop < threshold) {
254
- rebaseWeeks(1);
255
- }
256
- }
257
-
258
- /** Shift the rendered week window by SHIFT_WEEKS in the given direction. */
259
- function rebaseWeeks(direction: number) {
260
- if (rebasing) return;
261
- rebasing = true;
262
-
263
- // Save an anchor: first visible week row and its visual offset.
264
- const rows = el.querySelectorAll<HTMLElement>('[data-week]');
265
- let anchorWeekMs = 0;
266
- let anchorTop = 0;
267
- for (const row of rows) {
268
- const top = row.offsetTop - el.scrollTop;
269
- if (top + row.offsetHeight > 0) {
270
- anchorWeekMs = Number(row.dataset.week);
271
- anchorTop = top;
272
- break;
273
- }
274
- }
275
-
276
- const shift = SHIFT_WEEKS * direction;
277
- internalFocusMs += shift * customDays * DAY_MS;
278
- lastExternalMs = internalFocusMs;
279
- viewState?.setFocusDate(new Date(internalFocusMs));
280
-
281
- tick().then(() => {
282
- // Restore the anchor week to its previous visual position.
283
- const anchor = el.querySelector<HTMLElement>(`[data-week="${anchorWeekMs}"]`);
284
- if (anchor) {
285
- el.scrollTop = anchor.offsetTop - anchorTop;
286
- }
287
- rebasing = false;
288
- });
289
- }
290
-
291
- /** Push the currently visible centre week to viewState. */
292
- function syncFocusFromScroll() {
293
- if (!el || !viewState || rebasing) return;
294
- const centerY = el.scrollTop + el.clientHeight / 2;
295
- const rows = el.querySelectorAll<HTMLElement>('[data-week]');
296
- for (const row of rows) {
297
- if (row.offsetTop + row.offsetHeight >= centerY) {
298
- const ms = Number(row.dataset.week);
299
- if (ms && ms !== lastExternalMs) {
300
- lastExternalMs = ms;
301
- viewState.setFocusDate(new Date(ms));
302
- }
303
- break;
304
- }
305
- }
306
- }
307
-
238
+ // ─── Scroll state ─────────────────────────────────────
308
239
  function isCurrentWeekVisible(): boolean {
309
240
  if (!el) return false;
310
241
  const current = el.querySelector<HTMLElement>('.wg-week--current');
@@ -314,14 +245,26 @@
314
245
  return bottom > 0 && top < el.clientHeight;
315
246
  }
316
247
 
317
- let suppressScroll = false;
248
+ let extending = false;
318
249
 
319
250
  function handleUserScroll() {
320
- if (suppressScroll) return;
321
251
  scrolled = !isCurrentWeekVisible();
322
- if (!rebasing) {
323
- checkVerticalEdges();
324
- syncFocusFromScroll();
252
+ if (!el || extending) return;
253
+ // Extend buffer when user scrolls near the edge
254
+ if (el.scrollTop < EDGE_PX) {
255
+ extending = true;
256
+ const oldHeight = el.scrollHeight;
257
+ bufferBefore += EXTEND_BY;
258
+ // After DOM updates, compensate scroll for prepended content
259
+ tick().then(() => {
260
+ el.scrollTop += el.scrollHeight - oldHeight;
261
+ extending = false;
262
+ });
263
+ } else {
264
+ const bottomRemaining = el.scrollHeight - el.clientHeight - el.scrollTop;
265
+ if (bottomRemaining < EDGE_PX) {
266
+ bufferAfter += EXTEND_BY;
267
+ }
325
268
  }
326
269
  }
327
270
 
@@ -329,15 +272,9 @@
329
272
  internalFocusMs = clock.today;
330
273
  lastExternalMs = clock.today;
331
274
  viewState?.goToday();
332
- suppressScroll = true;
333
275
  scrolled = false;
334
276
  tick().then(() => {
335
- scrollCurrentWeekIntoContainer('smooth');
336
- // Keep suppressed until smooth scroll finishes, then recheck
337
- setTimeout(() => {
338
- suppressScroll = false;
339
- scrolled = !isCurrentWeekVisible();
340
- }, 600);
277
+ scrollWeekIntoContainer(clock.today, 'smooth');
341
278
  });
342
279
  }
343
280
 
@@ -363,6 +300,36 @@
363
300
  let evDragId = $state<string | null>(null);
364
301
  let evDragEvent: TimelineEvent | null = null;
365
302
 
303
+ const dragPreviewEvent = $derived.by(() => {
304
+ const payload = drag?.active && drag.mode === 'move' ? drag.payload : null;
305
+ if (!payload?.eventId) return null;
306
+ const ev = events.find((event) => event.id === payload.eventId);
307
+ if (!ev) return null;
308
+ return { ...ev, start: payload.start, end: payload.end };
309
+ });
310
+
311
+ function isDraggedEvent(eventId: string): boolean {
312
+ return dragPreviewEvent?.id === eventId;
313
+ }
314
+
315
+ function timedEventsForDay(day: DayCell): TimelineEvent[] {
316
+ if (!dragPreviewEvent) return day.events;
317
+ return day.events.filter((ev) => ev.id !== dragPreviewEvent.id);
318
+ }
319
+
320
+ function dragPreviewTimedForDay(dayMs: number): TimelineEvent | null {
321
+ const ev = dragPreviewEvent;
322
+ if (!ev || isAllDay(ev) || isMultiDay(ev)) return null;
323
+ const dayEnd = dayMs + DAY_MS;
324
+ return ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs ? ev : null;
325
+ }
326
+
327
+ function dragPreviewSegmentForDay(dayMs: number): DaySegment | null {
328
+ const ev = dragPreviewEvent;
329
+ if (!ev || (!isAllDay(ev) && !isMultiDay(ev))) return null;
330
+ return segmentForDay(ev, dayMs);
331
+ }
332
+
366
333
  function getCellWidth(): number {
367
334
  const cell = el?.querySelector('.wg-cell');
368
335
  return cell ? cell.getBoundingClientRect().width : 100;
@@ -374,7 +341,7 @@
374
341
  }
375
342
 
376
343
  function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
377
- if (e.button !== 0 || !drag || readOnly) return;
344
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
378
345
  e.stopPropagation();
379
346
  evDragStartX = e.clientX;
380
347
  evDragStartY = e.clientY;
@@ -437,6 +404,26 @@
437
404
  }
438
405
  </script>
439
406
 
407
+ {#snippet allDaySegmentContent(seg: DaySegment)}
408
+ {#if seg.isStart}
409
+ <span class="wg-ad-title">{seg.ev.title}</span>
410
+ {:else}
411
+ <span class="wg-ad-cont" aria-hidden="true">◂</span>
412
+ <span class="wg-ad-title">{seg.ev.title}</span>
413
+ {/if}
414
+ {#if !seg.isEnd && seg.totalDays > 1}
415
+ <span class="wg-ad-arrow" aria-hidden="true">▸</span>
416
+ {/if}
417
+ {/snippet}
418
+
419
+ {#snippet timedEventContent(ev: TimelineEvent)}
420
+ <span class="wg-ev-time">{fmtAmPm(ev.start)}</span>
421
+ <span class="wg-ev-title">{ev.title}</span>
422
+ {#if ev.location}
423
+ <span class="wg-ev-loc">{ev.location}</span>
424
+ {/if}
425
+ {/snippet}
426
+
440
427
  <div class="wg" class:wg--auto={autoHeight} style={style || undefined} style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')}>
441
428
  <div
442
429
  class="wg-body"
@@ -451,11 +438,17 @@
451
438
  <!-- Day columns (header inside each cell) -->
452
439
  <div class="wg-days">
453
440
  {#each week.days as day (day.ms)}
441
+ {@const visibleAllDaySegments = day.allDaySegments.filter((seg) => !isDraggedEvent(seg.ev.id))}
442
+ {@const visibleTimedEvents = timedEventsForDay(day)}
443
+ {@const previewTimedEvent = dragPreviewTimedForDay(day.ms)}
444
+ {@const previewSegment = dragPreviewSegmentForDay(day.ms)}
454
445
  <div
455
446
  class="wg-cell"
456
447
  class:wg-cell--today={day.isToday}
457
448
  class:wg-cell--past={day.isPast}
458
- class:wg-cell--weekend={day.isWeekend} class:wg-cell--disabled={disabledSet.has(day.ms)} role="gridcell"
449
+ class:wg-cell--weekend={day.isWeekend}
450
+ class:wg-cell--disabled={disabledSet.has(day.ms)}
451
+ role="gridcell"
459
452
  tabindex="0"
460
453
  aria-label="{new Date(day.ms).toLocaleDateString(locale ?? 'en-US', { weekday: 'long', month: 'short', day: 'numeric' })}{day.isToday ? ` (${L.today.toLowerCase()})` : ''}, {L.nEvents(day.events.length)}"
461
454
  onclick={(e) => handleDayCellClick(day.ms, e)}
@@ -498,9 +491,9 @@
498
491
  {/if}
499
492
 
500
493
  <!-- All-day / multi-day events -->
501
- {#if day.allDaySegments.length > 0}
494
+ {#if visibleAllDaySegments.length > 0 || previewSegment}
502
495
  <div class="wg-allday">
503
- {#each day.allDaySegments as seg (seg.ev.id)}
496
+ {#each visibleAllDaySegments as seg (seg.ev.id)}
504
497
  <div
505
498
  class="wg-ad"
506
499
  class:wg-ad--start={seg.isStart}
@@ -514,41 +507,59 @@
514
507
  onpointerdown={(e) => onEventPointerDown(e, seg.ev)}
515
508
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(seg.ev); } }}
516
509
  >
517
- {#if seg.isStart}
518
- <span class="wg-ad-title">{seg.ev.title}</span>
519
- {:else}
520
- <span class="wg-ad-cont" aria-hidden="true">◂</span>
521
- <span class="wg-ad-title">{seg.ev.title}</span>
522
- {/if}
523
- {#if !seg.isEnd && seg.totalDays > 1}
524
- <span class="wg-ad-arrow" aria-hidden="true">▸</span>
525
- {/if}
510
+ {@render allDaySegmentContent(seg)}
526
511
  </div>
527
512
  {/each}
513
+ {#if previewSegment}
514
+ <div
515
+ class="wg-ad wg-ad--drag-preview"
516
+ class:wg-ad--start={previewSegment.isStart}
517
+ class:wg-ad--end={previewSegment.isEnd}
518
+ class:wg-ad--mid={!previewSegment.isStart && !previewSegment.isEnd}
519
+ style:--ev-color={previewSegment.ev.color ?? 'var(--dt-accent)'}
520
+ aria-hidden="true"
521
+ >
522
+ {@render allDaySegmentContent(previewSegment)}
523
+ </div>
524
+ {/if}
528
525
  </div>
529
526
  {/if}
530
527
 
531
528
  <!-- Timed events -->
532
529
  <div class="wg-cell-events">
533
- {#each day.events.slice(0, MAX_EVENTS_SHOWN) as ev (ev.id)}
530
+ {#each visibleTimedEvents.slice(0, MAX_EVENTS_SHOWN) as ev (ev.id)}
534
531
  <div
535
532
  class="wg-ev"
536
533
  class:wg-ev--selected={selectedEventId === ev.id}
537
534
  class:wg-ev--current={ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
538
535
  class:wg-ev--dragging={evDragging && evDragId === ev.id}
536
+ class:wg-ev--readonly={ev.data?.readOnly}
537
+ class:wg-ev--cancelled={ev.status === 'cancelled'}
538
+ class:wg-ev--tentative={ev.status === 'tentative'}
539
+ class:wg-ev--full={ev.status === 'full'}
540
+ class:wg-ev--limited={ev.status === 'limited'}
539
541
  style:--ev-color={ev.color ?? 'var(--dt-accent)'}
540
542
  role="button"
541
543
  tabindex="0"
542
- aria-label="{ev.title}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
543
- onpointerdown={(e) => onEventPointerDown(e, ev)} onpointerenter={() => oneventhover?.(ev)}
544
+ aria-label="{ev.title}{ev.status === 'cancelled' ? ` (cancelled)` : ''}{ev.status === 'tentative' ? ` (tentative)` : ''}{ev.status === 'full' ? ` (full)` : ''}{ev.status === 'limited' ? ` (limited)` : ''}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
545
+ onpointerdown={(e) => onEventPointerDown(e, ev)}
546
+ onpointerenter={() => oneventhover?.(ev)}
544
547
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev); } }}
545
548
  >
546
- <span class="wg-ev-time">{fmtAmPm(ev.start)}</span>
547
- <span class="wg-ev-title">{ev.title}</span>
549
+ {@render timedEventContent(ev)}
548
550
  </div>
549
551
  {/each}
550
- {#if day.events.length > MAX_EVENTS_SHOWN}
551
- <div class="wg-ev-more">{L.nMore(day.events.length - MAX_EVENTS_SHOWN)}</div>
552
+ {#if previewTimedEvent}
553
+ <div
554
+ class="wg-ev wg-ev--drag-preview"
555
+ style:--ev-color={previewTimedEvent.color ?? 'var(--dt-accent)'}
556
+ aria-hidden="true"
557
+ >
558
+ {@render timedEventContent(previewTimedEvent)}
559
+ </div>
560
+ {/if}
561
+ {#if visibleTimedEvents.length > MAX_EVENTS_SHOWN}
562
+ <div class="wg-ev-more">{L.nMore(visibleTimedEvents.length - MAX_EVENTS_SHOWN)}</div>
552
563
  {/if}
553
564
  </div>
554
565
  </div>
@@ -589,6 +600,8 @@
589
600
  flex: 1;
590
601
  overflow-y: auto;
591
602
  overflow-x: hidden;
603
+ box-sizing: border-box;
604
+ padding-top: 48px;
592
605
  scrollbar-width: thin;
593
606
  scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.08)) transparent;
594
607
  }
@@ -612,8 +625,8 @@
612
625
 
613
626
  .wg-week--current {
614
627
  background: var(--dt-today-bg, rgba(239, 68, 68, 0.02));
615
- border: 2.5px solid var(--dt-accent, #ef4444);
616
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--dt-accent, #ef4444) 15%, transparent);
628
+ border: 2.5px solid var(--dt-accent, #2563eb);
629
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--dt-accent, #2563eb) 15%, transparent);
617
630
  }
618
631
 
619
632
  /* ─── Week body ──────────────────────────────────── */
@@ -659,8 +672,6 @@
659
672
 
660
673
  /* ─── Disabled cell ──────────────────────────────── */
661
674
  .wg-cell--disabled {
662
- opacity: 0.35;
663
- pointer-events: none;
664
675
  background: repeating-linear-gradient(
665
676
  45deg,
666
677
  transparent,
@@ -723,7 +734,7 @@
723
734
  }
724
735
 
725
736
  .wg-cell-hd--today .wg-day-wd {
726
- color: var(--dt-accent, #ef4444);
737
+ color: var(--dt-accent, #2563eb);
727
738
  font-weight: 600;
728
739
  }
729
740
 
@@ -737,7 +748,7 @@
737
748
  }
738
749
 
739
750
  .wg-day-num--today {
740
- color: var(--dt-accent, #ef4444);
751
+ color: var(--dt-accent, #2563eb);
741
752
  font-weight: 900;
742
753
  }
743
754
 
@@ -774,15 +785,25 @@
774
785
  gap: 3px;
775
786
  padding: 2px 5px;
776
787
  border-radius: 3px;
777
- background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #10141c));
788
+ background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
778
789
  cursor: pointer;
779
790
  overflow: hidden;
780
791
  transition: background 0.12s;
781
792
  min-height: 18px;
782
793
  }
783
794
 
795
+ .wg-ad--drag-preview {
796
+ position: relative;
797
+ z-index: 8;
798
+ opacity: 0.95;
799
+ pointer-events: none;
800
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--ev-color) 26%, rgba(0, 0, 0, 0.22));
801
+ outline: 1px solid color-mix(in srgb, var(--ev-color) 42%, transparent);
802
+ cursor: grabbing;
803
+ }
804
+
784
805
  .wg-ad:hover {
785
- background: color-mix(in srgb, var(--ev-color) 32%, var(--dt-surface, #10141c));
806
+ background: color-mix(in srgb, var(--ev-color) 32%, var(--dt-surface, var(--dt-bg, #ffffff)));
786
807
  }
787
808
 
788
809
  .wg-ad--start {
@@ -842,14 +863,25 @@
842
863
  gap: 3px 5px;
843
864
  padding: 3px 6px;
844
865
  border-radius: 4px;
845
- background: color-mix(in srgb, var(--ev-color) 15%, var(--dt-surface, #10141c));
866
+ background: color-mix(in srgb, var(--ev-color) 15%, var(--dt-surface, var(--dt-bg, #ffffff)));
846
867
  cursor: pointer;
847
868
  overflow: hidden;
848
869
  transition: background 0.12s;
849
870
  }
850
871
 
851
872
  .wg-ev:hover {
852
- background: color-mix(in srgb, var(--ev-color) 25%, var(--dt-surface, #10141c));
873
+ background: color-mix(in srgb, var(--ev-color) 25%, var(--dt-surface, var(--dt-bg, #ffffff)));
874
+ }
875
+
876
+ .wg-ev--drag-preview {
877
+ position: relative;
878
+ z-index: 8;
879
+ opacity: 0.95;
880
+ pointer-events: none;
881
+ background: color-mix(in srgb, var(--ev-color) 28%, var(--dt-surface, var(--dt-bg, #ffffff)));
882
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--ev-color) 24%, rgba(0, 0, 0, 0.22));
883
+ outline: 1px solid color-mix(in srgb, var(--ev-color) 42%, transparent);
884
+ cursor: grabbing;
853
885
  }
854
886
 
855
887
  .wg-ev--selected {
@@ -857,7 +889,28 @@
857
889
  }
858
890
 
859
891
  .wg-ev--current {
860
- background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #10141c));
892
+ background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
893
+ }
894
+
895
+ .wg-ev--cancelled {
896
+ opacity: 0.5;
897
+ }
898
+ .wg-ev--cancelled .wg-ev-title {
899
+ text-decoration: line-through;
900
+ }
901
+ .wg-ev--tentative {
902
+ opacity: 0.65;
903
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
904
+ }
905
+ .wg-ev--full {
906
+ opacity: 0.55;
907
+ }
908
+ .wg-ev--limited {
909
+ opacity: 0.65;
910
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
911
+ }
912
+ .wg-ev--readonly {
913
+ cursor: default;
861
914
  }
862
915
 
863
916
  .wg-ev-time {
@@ -875,6 +928,15 @@
875
928
  text-overflow: ellipsis;
876
929
  }
877
930
 
931
+ .wg-ev-loc {
932
+ font: 400 9px / 1 var(--dt-sans, system-ui, sans-serif);
933
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.35));
934
+ white-space: nowrap;
935
+ overflow: hidden;
936
+ text-overflow: ellipsis;
937
+ flex-shrink: 1;
938
+ }
939
+
878
940
  .wg-ev-more {
879
941
  font: 500 10px / 1 var(--dt-sans, system-ui, sans-serif);
880
942
  color: var(--dt-text-3, rgba(0, 0, 0, 0.35));
@@ -894,7 +956,7 @@
894
956
  z-index: 20;
895
957
  display: flex;
896
958
  gap: 2px;
897
- background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
959
+ background: color-mix(in srgb, var(--dt-surface, var(--dt-bg, #ffffff)) 85%, transparent);
898
960
  backdrop-filter: blur(6px);
899
961
  -webkit-backdrop-filter: blur(6px);
900
962
  border-radius: 8px;
@@ -911,7 +973,7 @@
911
973
  background: transparent;
912
974
  color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
913
975
  cursor: pointer;
914
- font: 600 11px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
976
+ font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
915
977
  padding: 6px 12px;
916
978
  border-radius: 6px;
917
979
  letter-spacing: 0.04em;
@@ -925,18 +987,18 @@
925
987
  color: var(--dt-text, rgba(226, 232, 240, 0.85));
926
988
  }
927
989
  .wg-nav-pill:focus-visible {
928
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
990
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
929
991
  outline-offset: 2px;
930
992
  }
931
993
 
932
994
  /* ─── Focus-visible ──────────────────────────────── */
933
995
  .wg-cell:focus-visible {
934
- outline: 2px solid var(--dt-accent, #ef4444);
996
+ outline: 2px solid var(--dt-accent, #2563eb);
935
997
  outline-offset: -2px;
936
998
  }
937
999
 
938
1000
  .wg-ev:focus-visible {
939
- outline: 2px solid var(--ev-color, var(--dt-accent, #ef4444));
1001
+ outline: 2px solid var(--ev-color, var(--dt-accent, #2563eb));
940
1002
  outline-offset: 1px;
941
1003
  }
942
1004
 
@@ -0,0 +1,47 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
3
+ import type { DragState } from '../../engine/drag.svelte.js';
4
+ import type { ViewState } from '../../engine/view-state.svelte.js';
5
+ export interface CalendarContext {
6
+ readonly viewState: ViewState | undefined;
7
+ readonly drag: DragState | undefined;
8
+ readonly commitDrag: (() => void) | undefined;
9
+ readonly snapInterval: number;
10
+ readonly showNav: boolean;
11
+ readonly equalDays: boolean;
12
+ readonly showDates: boolean;
13
+ readonly hideDays: number[] | undefined;
14
+ readonly isMobile: boolean;
15
+ readonly autoHeight: boolean;
16
+ readonly compact: boolean;
17
+ readonly readOnly: boolean;
18
+ readonly blockedSlots: BlockedSlot[] | undefined;
19
+ readonly dayHeaderSnippet: Snippet<[{
20
+ date: Date;
21
+ isToday: boolean;
22
+ dayName: string;
23
+ }]> | undefined;
24
+ readonly minDuration: number | undefined;
25
+ readonly maxDuration: number | undefined;
26
+ readonly oneventhover: ((event: TimelineEvent) => void) | undefined;
27
+ readonly disabledDates: Date[] | undefined;
28
+ readonly disabledSet: Set<number>;
29
+ readonly loadRange: {
30
+ current: {
31
+ start: Date;
32
+ end: Date;
33
+ } | null;
34
+ set: (r: {
35
+ start: Date;
36
+ end: Date;
37
+ } | null) => void;
38
+ } | undefined;
39
+ readonly eventSnippet: Snippet<[TimelineEvent]> | undefined;
40
+ readonly emptySnippet: Snippet | undefined;
41
+ }
42
+ /**
43
+ * Read the calendar context.
44
+ * Call at component init time (top-level script).
45
+ * Returns an object with getters that delegate to the raw context.
46
+ */
47
+ export declare function useCalendarContext(): CalendarContext;