@nomideusz/svelte-calendar 0.6.3 → 0.6.4
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.
- package/README.md +183 -5
- package/dist/adapters/composite.d.ts +15 -0
- package/dist/adapters/composite.js +65 -0
- package/dist/adapters/index.d.ts +5 -1
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/mapped.d.ts +170 -0
- package/dist/adapters/mapped.js +343 -0
- package/dist/adapters/recurring.d.ts +4 -0
- package/dist/adapters/recurring.js +5 -10
- package/dist/adapters/types.d.ts +11 -2
- package/dist/calendar/Calendar.svelte +147 -76
- package/dist/calendar/Calendar.svelte.d.ts +12 -0
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +2 -0
- package/dist/core/measure.d.ts +104 -0
- package/dist/core/measure.js +195 -0
- package/dist/core/time.js +2 -2
- package/dist/core/types.d.ts +32 -0
- package/dist/engine/event-store.svelte.js +22 -7
- package/dist/headless/create-agenda.svelte.d.ts +67 -0
- package/dist/headless/create-agenda.svelte.js +139 -0
- package/dist/headless/create-calendar.svelte.d.ts +2 -0
- package/dist/headless/create-calendar.svelte.js +380 -0
- package/dist/headless/index.d.ts +4 -0
- package/dist/headless/index.js +2 -0
- package/dist/headless/types.d.ts +219 -0
- package/dist/headless/types.js +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/primitives/EventBlock.svelte +87 -2
- package/dist/views/agenda/AgendaDay.svelte +183 -89
- package/dist/views/agenda/AgendaWeek.svelte +148 -95
- package/dist/views/mobile/MobileDay.svelte +43 -16
- package/dist/views/mobile/MobileWeek.svelte +32 -17
- package/dist/views/planner/PlannerDay.svelte +91 -36
- package/dist/views/planner/PlannerWeek.svelte +107 -132
- package/dist/views/shared/context.svelte.d.ts +47 -0
- package/dist/views/shared/context.svelte.js +47 -0
- package/dist/views/shared/format.d.ts +19 -0
- package/dist/views/shared/format.js +46 -0
- package/dist/views/shared/index.d.ts +4 -0
- package/dist/views/shared/index.js +2 -0
- package/package.json +76 -71
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
Events are positioned as horizontal cards with overlap support.
|
|
7
7
|
-->
|
|
8
8
|
<script lang="ts">
|
|
9
|
-
import {
|
|
9
|
+
import { onMount, tick, untrack } from 'svelte';
|
|
10
|
+
import { useCalendarContext } from '../shared/context.svelte.js';
|
|
10
11
|
import { fly } from 'svelte/transition';
|
|
11
12
|
import { createClock } from '../../core/clock.svelte.js';
|
|
13
|
+
import { createTextMeasure, type ContentFit } from '../../core/measure.js';
|
|
12
14
|
import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
|
|
13
15
|
import type { DragState } from '../../engine/drag.svelte.js';
|
|
14
16
|
import type { ViewState } from '../../engine/view-state.svelte.js';
|
|
@@ -58,33 +60,21 @@
|
|
|
58
60
|
}: Props = $props();
|
|
59
61
|
|
|
60
62
|
// ── Context (available when inside Calendar) ──
|
|
61
|
-
const
|
|
62
|
-
const commitDragCtx = getContext<() => void>('calendar:commitDrag') as (() => void) | undefined;
|
|
63
|
-
const snapIntervalCtx = getContext<{ current: number }>('calendar:snapInterval');
|
|
64
|
-
const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
|
|
65
|
-
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;
|
|
66
|
-
|
|
63
|
+
const ctx = useCalendarContext();
|
|
67
64
|
const clock = createClock();
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const blockedSlots = $derived(blockedSlotsCtx?.current);
|
|
82
|
-
const dayHeaderSnippet = $derived(dayHeaderSnippetCtx?.current);
|
|
83
|
-
const minDuration = $derived(minDurationCtx?.current);
|
|
84
|
-
const autoHeight = $derived(autoHeightCtx?.current ?? false);
|
|
85
|
-
const oneventhover = $derived(callbacksCtx?.oneventhover);
|
|
86
|
-
const disabledDates = $derived(disabledDatesCtx?.current);
|
|
87
|
-
const disabledSet = $derived(new Set(disabledDates?.map(d => sod(d.getTime())) ?? []));
|
|
65
|
+
const drag = $derived(ctx.drag);
|
|
66
|
+
const commitDragCtx = $derived(ctx.commitDrag);
|
|
67
|
+
const viewState = $derived(ctx.viewState);
|
|
68
|
+
const loadRangeCtx = $derived(ctx.loadRange);
|
|
69
|
+
const showNav = $derived(ctx.showNav);
|
|
70
|
+
const showDates = $derived(ctx.showDates);
|
|
71
|
+
const blockedSlots = $derived(ctx.blockedSlots);
|
|
72
|
+
const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
|
|
73
|
+
const minDuration = $derived(ctx.minDuration);
|
|
74
|
+
const autoHeight = $derived(ctx.autoHeight);
|
|
75
|
+
const oneventhover = $derived(ctx.oneventhover);
|
|
76
|
+
const disabledSet = $derived(ctx.disabledSet);
|
|
77
|
+
const SNAP_MS = $derived(ctx.snapInterval * 60_000);
|
|
88
78
|
|
|
89
79
|
// ─── State ──────────────────────────────────────────
|
|
90
80
|
let following = $state(true);
|
|
@@ -113,7 +103,6 @@
|
|
|
113
103
|
const startHour = $derived(visibleHours?.[0] ?? 0);
|
|
114
104
|
const endHour = $derived(visibleHours?.[1] ?? 24);
|
|
115
105
|
const hourCount = $derived(Math.max(1, endHour - startHour));
|
|
116
|
-
const SNAP_MS = $derived((snapIntervalCtx?.current ?? 15) * 60_000);
|
|
117
106
|
const DAY_GAP = 2;
|
|
118
107
|
|
|
119
108
|
const dateLabel = $derived(
|
|
@@ -207,6 +196,16 @@
|
|
|
207
196
|
const EVENT_GAP = 5;
|
|
208
197
|
const MIN_EVENT_H = 32;
|
|
209
198
|
|
|
199
|
+
// Text measure for smart content fitting
|
|
200
|
+
const measure = createTextMeasure({
|
|
201
|
+
titleFont: '600 13px system-ui, sans-serif',
|
|
202
|
+
secondaryFont: '400 10px system-ui, sans-serif',
|
|
203
|
+
tagFont: '500 8px system-ui, sans-serif',
|
|
204
|
+
titleLineHeight: 16,
|
|
205
|
+
secondaryLineHeight: 13,
|
|
206
|
+
contentGap: 6,
|
|
207
|
+
});
|
|
208
|
+
|
|
210
209
|
interface PositionedEvent {
|
|
211
210
|
ev: TimelineEvent;
|
|
212
211
|
x: number;
|
|
@@ -218,6 +217,7 @@
|
|
|
218
217
|
isCurrent: boolean;
|
|
219
218
|
isNext: boolean;
|
|
220
219
|
isDragged: boolean;
|
|
220
|
+
fit: ContentFit;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
const positionedEvents = $derived.by(() => {
|
|
@@ -297,20 +297,40 @@
|
|
|
297
297
|
const result: PositionedEvent[] = infos.map(({ startMs: _s, endMs: _e, ...info }) => {
|
|
298
298
|
const laneH = Math.max(MIN_EVENT_H, availH / info.groupMaxRow - EVENT_GAP);
|
|
299
299
|
const topPx = contentTop + info.row * (availH / info.groupMaxRow);
|
|
300
|
-
|
|
300
|
+
// Vertical text: text "width" = heightPx, content "height" = width
|
|
301
|
+
const fit = measure.fitContent({
|
|
302
|
+
title: info.ev.title,
|
|
303
|
+
subtitle: info.ev.subtitle,
|
|
304
|
+
location: info.ev.location,
|
|
305
|
+
time: `${fmtTime(info.ev.start, locale)} – ${fmtTime(info.ev.end, locale)}`,
|
|
306
|
+
tags: info.ev.tags,
|
|
307
|
+
maxWidth: laneH - 16, // vertical: heightPx minus padding
|
|
308
|
+
maxHeight: info.width - 16, // vertical: width minus padding
|
|
309
|
+
});
|
|
310
|
+
return { ...info, topPx, heightPx: laneH, isNext: info.isNext, fit };
|
|
301
311
|
});
|
|
302
312
|
|
|
303
313
|
if (draggedEv && dragP) {
|
|
304
314
|
const x = timeToPx(dragP.start.getTime());
|
|
305
315
|
const xEnd = timeToPx(dragP.end.getTime());
|
|
316
|
+
const dragH = Math.max(MIN_EVENT_H, availH - EVENT_GAP);
|
|
317
|
+
const dragW = Math.max(xEnd - x, 28);
|
|
306
318
|
result.push({
|
|
307
|
-
ev: draggedEv, x, width:
|
|
319
|
+
ev: draggedEv, x, width: dragW,
|
|
308
320
|
row: 0, groupMaxRow: 1,
|
|
309
321
|
topPx: contentTop,
|
|
310
|
-
heightPx:
|
|
322
|
+
heightPx: dragH,
|
|
311
323
|
isCurrent: draggedEv.start.getTime() <= now && draggedEv.end.getTime() > now,
|
|
312
324
|
isNext: false,
|
|
313
325
|
isDragged: true,
|
|
326
|
+
fit: measure.fitContent({
|
|
327
|
+
title: draggedEv.title,
|
|
328
|
+
subtitle: draggedEv.subtitle,
|
|
329
|
+
location: draggedEv.location,
|
|
330
|
+
tags: draggedEv.tags,
|
|
331
|
+
maxWidth: dragH - 16,
|
|
332
|
+
maxHeight: dragW - 16,
|
|
333
|
+
}),
|
|
314
334
|
});
|
|
315
335
|
}
|
|
316
336
|
|
|
@@ -493,7 +513,7 @@
|
|
|
493
513
|
let evDragEvent: TimelineEvent | null = null;
|
|
494
514
|
|
|
495
515
|
function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
|
|
496
|
-
if (e.button !== 0 || !drag || readOnly) return;
|
|
516
|
+
if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
|
|
497
517
|
e.stopPropagation();
|
|
498
518
|
evDragStartX = e.clientX;
|
|
499
519
|
evDragOriginPx = timeToPx(ev.start.getTime());
|
|
@@ -623,6 +643,11 @@
|
|
|
623
643
|
class:fs-event--current={p.isCurrent}
|
|
624
644
|
class:fs-event--next={p.isNext}
|
|
625
645
|
class:fs-event--dragging={p.isDragged}
|
|
646
|
+
class:fs-event--readonly={p.ev.data?.readOnly}
|
|
647
|
+
class:fs-event--cancelled={p.ev.status === 'cancelled'}
|
|
648
|
+
class:fs-event--tentative={p.ev.status === 'tentative'}
|
|
649
|
+
class:fs-event--full={p.ev.status === 'full'}
|
|
650
|
+
class:fs-event--limited={p.ev.status === 'limited'}
|
|
626
651
|
style:left="{p.x}px"
|
|
627
652
|
style:width="{p.width}px"
|
|
628
653
|
style:top="{p.topPx}px"
|
|
@@ -630,7 +655,7 @@
|
|
|
630
655
|
style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
|
|
631
656
|
role="button"
|
|
632
657
|
tabindex="0"
|
|
633
|
-
aria-label="{p.ev.title}{p.isCurrent ? ` (${L.inProgress})` : ''}{p.isNext ? ` (${L.upNext})` : ''}"
|
|
658
|
+
aria-label="{p.ev.title}{p.ev.status === 'cancelled' ? ' (cancelled)' : ''}{p.ev.status === 'tentative' ? ' (tentative)' : ''}{p.ev.status === 'full' ? ' (full)' : ''}{p.ev.status === 'limited' ? ' (limited)' : ''}{p.isCurrent ? ` (${L.inProgress})` : ''}{p.isNext ? ` (${L.upNext})` : ''}"
|
|
634
659
|
onpointerdown={(e) => onEventPointerDown(e, p.ev)}
|
|
635
660
|
onpointerenter={() => oneventhover?.(p.ev)}
|
|
636
661
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oneventclick?.(p.ev); } }}
|
|
@@ -641,14 +666,17 @@
|
|
|
641
666
|
{:else if p.isNext}
|
|
642
667
|
<span class="fs-ev-next-badge" aria-hidden="true">{L.upNext}</span>
|
|
643
668
|
{/if}
|
|
644
|
-
{#if p.
|
|
669
|
+
{#if p.fit.time}
|
|
645
670
|
<span class="fs-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
|
|
646
671
|
{/if}
|
|
647
672
|
<span class="fs-ev-title">{p.ev.title}</span>
|
|
648
|
-
{#if p.ev.subtitle && p.
|
|
673
|
+
{#if p.ev.subtitle && p.fit.subtitle}
|
|
649
674
|
<span class="fs-ev-sub">{p.ev.subtitle}</span>
|
|
650
675
|
{/if}
|
|
651
|
-
{#if p.ev.
|
|
676
|
+
{#if p.ev.location && p.fit.location}
|
|
677
|
+
<span class="fs-ev-loc">{p.ev.location}</span>
|
|
678
|
+
{/if}
|
|
679
|
+
{#if p.ev.tags?.length && p.fit.tags}
|
|
652
680
|
<span class="fs-ev-tags">
|
|
653
681
|
{#each p.ev.tags as tag}
|
|
654
682
|
<span class="fs-ev-tag">{tag}</span>
|
|
@@ -1058,6 +1086,26 @@
|
|
|
1058
1086
|
cursor: grabbing;
|
|
1059
1087
|
transition: none;
|
|
1060
1088
|
}
|
|
1089
|
+
.fs-event--cancelled {
|
|
1090
|
+
opacity: 0.5;
|
|
1091
|
+
}
|
|
1092
|
+
.fs-event--cancelled .fs-ev-title {
|
|
1093
|
+
text-decoration: line-through;
|
|
1094
|
+
}
|
|
1095
|
+
.fs-event--tentative {
|
|
1096
|
+
opacity: 0.65;
|
|
1097
|
+
border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
|
|
1098
|
+
}
|
|
1099
|
+
.fs-event--full {
|
|
1100
|
+
opacity: 0.55;
|
|
1101
|
+
}
|
|
1102
|
+
.fs-event--limited {
|
|
1103
|
+
opacity: 0.65;
|
|
1104
|
+
border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
|
|
1105
|
+
}
|
|
1106
|
+
.fs-event--readonly {
|
|
1107
|
+
cursor: default;
|
|
1108
|
+
}
|
|
1061
1109
|
|
|
1062
1110
|
/* Event inner (vertical text, bottom-to-top reading) */
|
|
1063
1111
|
.fs-ev-inner {
|
|
@@ -1109,6 +1157,13 @@
|
|
|
1109
1157
|
overflow: hidden;
|
|
1110
1158
|
text-overflow: ellipsis;
|
|
1111
1159
|
}
|
|
1160
|
+
.fs-ev-loc {
|
|
1161
|
+
font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
|
|
1162
|
+
color: var(--dt-text-3, rgba(148, 163, 184, 0.5));
|
|
1163
|
+
white-space: nowrap;
|
|
1164
|
+
overflow: hidden;
|
|
1165
|
+
text-overflow: ellipsis;
|
|
1166
|
+
}
|
|
1112
1167
|
.fs-ev-tags { display: flex; gap: 4px; }
|
|
1113
1168
|
.fs-ev-tag {
|
|
1114
1169
|
font: 500 8px/1 var(--dt-sans, system-ui, sans-serif);
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
• Generous whitespace, thin dividers, minimal chrome.
|
|
10
10
|
-->
|
|
11
11
|
<script lang="ts">
|
|
12
|
-
import {
|
|
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
|
-
|
|
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
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const showNav = $derived(
|
|
63
|
-
const equalDays = $derived(
|
|
64
|
-
const showDates = $derived(
|
|
65
|
-
const hideDays = $derived(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
84
|
+
function scrollWeekIntoContainer(targetMs?: number, behavior: ScrollBehavior = 'auto') {
|
|
99
85
|
if (!el) return;
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
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 -
|
|
121
|
-
const rangeEnd = new Date(anchorPeriodStart + (
|
|
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 = -
|
|
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
|
-
|
|
225
|
+
scrollWeekIntoContainer();
|
|
227
226
|
});
|
|
228
227
|
|
|
229
|
-
// ───
|
|
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
|
|
231
|
+
if (ext !== lastExternalMs) {
|
|
235
232
|
lastExternalMs = ext;
|
|
236
233
|
internalFocusMs = ext;
|
|
237
|
-
tick().then(() =>
|
|
234
|
+
tick().then(() => scrollWeekIntoContainer(ext, 'smooth'));
|
|
238
235
|
}
|
|
239
236
|
});
|
|
240
237
|
|
|
241
|
-
|
|
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
|
|
248
|
+
let extending = false;
|
|
318
249
|
|
|
319
250
|
function handleUserScroll() {
|
|
320
|
-
if (suppressScroll) return;
|
|
321
251
|
scrolled = !isCurrentWeekVisible();
|
|
322
|
-
if (!
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -374,7 +311,7 @@
|
|
|
374
311
|
}
|
|
375
312
|
|
|
376
313
|
function onEventPointerDown(e: PointerEvent, ev: TimelineEvent) {
|
|
377
|
-
if (e.button !== 0 || !drag || readOnly) return;
|
|
314
|
+
if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
|
|
378
315
|
e.stopPropagation();
|
|
379
316
|
evDragStartX = e.clientX;
|
|
380
317
|
evDragStartY = e.clientY;
|
|
@@ -536,15 +473,23 @@
|
|
|
536
473
|
class:wg-ev--selected={selectedEventId === ev.id}
|
|
537
474
|
class:wg-ev--current={ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
|
|
538
475
|
class:wg-ev--dragging={evDragging && evDragId === ev.id}
|
|
476
|
+
class:wg-ev--readonly={ev.data?.readOnly}
|
|
477
|
+
class:wg-ev--cancelled={ev.status === 'cancelled'}
|
|
478
|
+
class:wg-ev--tentative={ev.status === 'tentative'}
|
|
479
|
+
class:wg-ev--full={ev.status === 'full'}
|
|
480
|
+
class:wg-ev--limited={ev.status === 'limited'}
|
|
539
481
|
style:--ev-color={ev.color ?? 'var(--dt-accent)'}
|
|
540
482
|
role="button"
|
|
541
483
|
tabindex="0"
|
|
542
|
-
aria-label="{ev.title}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
|
|
484
|
+
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})` : ''}"
|
|
543
485
|
onpointerdown={(e) => onEventPointerDown(e, ev)} onpointerenter={() => oneventhover?.(ev)}
|
|
544
486
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev); } }}
|
|
545
487
|
>
|
|
546
488
|
<span class="wg-ev-time">{fmtAmPm(ev.start)}</span>
|
|
547
489
|
<span class="wg-ev-title">{ev.title}</span>
|
|
490
|
+
{#if ev.location}
|
|
491
|
+
<span class="wg-ev-loc">{ev.location}</span>
|
|
492
|
+
{/if}
|
|
548
493
|
</div>
|
|
549
494
|
{/each}
|
|
550
495
|
{#if day.events.length > MAX_EVENTS_SHOWN}
|
|
@@ -860,6 +805,27 @@
|
|
|
860
805
|
background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #10141c));
|
|
861
806
|
}
|
|
862
807
|
|
|
808
|
+
.wg-ev--cancelled {
|
|
809
|
+
opacity: 0.5;
|
|
810
|
+
}
|
|
811
|
+
.wg-ev--cancelled .wg-ev-title {
|
|
812
|
+
text-decoration: line-through;
|
|
813
|
+
}
|
|
814
|
+
.wg-ev--tentative {
|
|
815
|
+
opacity: 0.65;
|
|
816
|
+
border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
|
|
817
|
+
}
|
|
818
|
+
.wg-ev--full {
|
|
819
|
+
opacity: 0.55;
|
|
820
|
+
}
|
|
821
|
+
.wg-ev--limited {
|
|
822
|
+
opacity: 0.65;
|
|
823
|
+
border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
|
|
824
|
+
}
|
|
825
|
+
.wg-ev--readonly {
|
|
826
|
+
cursor: default;
|
|
827
|
+
}
|
|
828
|
+
|
|
863
829
|
.wg-ev-time {
|
|
864
830
|
font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
|
|
865
831
|
color: var(--dt-text-3, rgba(0, 0, 0, 0.4));
|
|
@@ -875,6 +841,15 @@
|
|
|
875
841
|
text-overflow: ellipsis;
|
|
876
842
|
}
|
|
877
843
|
|
|
844
|
+
.wg-ev-loc {
|
|
845
|
+
font: 400 9px / 1 var(--dt-sans, system-ui, sans-serif);
|
|
846
|
+
color: var(--dt-text-3, rgba(0, 0, 0, 0.35));
|
|
847
|
+
white-space: nowrap;
|
|
848
|
+
overflow: hidden;
|
|
849
|
+
text-overflow: ellipsis;
|
|
850
|
+
flex-shrink: 1;
|
|
851
|
+
}
|
|
852
|
+
|
|
878
853
|
.wg-ev-more {
|
|
879
854
|
font: 500 10px / 1 var(--dt-sans, system-ui, sans-serif);
|
|
880
855
|
color: var(--dt-text-3, rgba(0, 0, 0, 0.35));
|
|
@@ -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;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared context reader for calendar views.
|
|
3
|
+
*
|
|
4
|
+
* Calendar.svelte sets a single 'calendar' context with reactive getters.
|
|
5
|
+
* This module reads it and returns a typed interface for views.
|
|
6
|
+
*/
|
|
7
|
+
import { getContext } from 'svelte';
|
|
8
|
+
import { sod } from '../../core/time.js';
|
|
9
|
+
/**
|
|
10
|
+
* Read the calendar context.
|
|
11
|
+
* Call at component init time (top-level script).
|
|
12
|
+
* Returns an object with getters that delegate to the raw context.
|
|
13
|
+
*/
|
|
14
|
+
export function useCalendarContext() {
|
|
15
|
+
const raw = getContext('calendar');
|
|
16
|
+
return {
|
|
17
|
+
get viewState() { return raw?.viewState; },
|
|
18
|
+
get drag() { return raw?.drag; },
|
|
19
|
+
get commitDrag() { return raw?.commitDrag; },
|
|
20
|
+
get snapInterval() { return raw?.snapInterval ?? 15; },
|
|
21
|
+
get showNav() { return raw?.showNavigation ?? true; },
|
|
22
|
+
get equalDays() { return raw?.equalDays ?? false; },
|
|
23
|
+
get showDates() { return raw?.showDates ?? true; },
|
|
24
|
+
get hideDays() { return raw?.hideDays; },
|
|
25
|
+
get isMobile() { return raw?.mobile ?? false; },
|
|
26
|
+
get autoHeight() { return raw?.autoHeight ?? false; },
|
|
27
|
+
get compact() { return raw?.compact ?? false; },
|
|
28
|
+
get readOnly() { return raw?.readOnly ?? false; },
|
|
29
|
+
get blockedSlots() { return raw?.blockedSlots; },
|
|
30
|
+
get dayHeaderSnippet() { return raw?.dayHeaderSnippet; },
|
|
31
|
+
get minDuration() { return raw?.minDuration; },
|
|
32
|
+
get maxDuration() { return raw?.maxDuration; },
|
|
33
|
+
get oneventhover() { return raw?.oneventhover; },
|
|
34
|
+
get disabledDates() { return raw?.disabledDates; },
|
|
35
|
+
get disabledSet() { return new Set(raw?.disabledDates?.map(d => sod(d.getTime())) ?? []); },
|
|
36
|
+
get loadRange() {
|
|
37
|
+
if (!raw)
|
|
38
|
+
return undefined;
|
|
39
|
+
return {
|
|
40
|
+
get current() { return raw.loadRange; },
|
|
41
|
+
set: (r) => raw.setLoadRange(r),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
get eventSnippet() { return raw?.eventSnippet; },
|
|
45
|
+
get emptySnippet() { return raw?.emptySnippet; },
|
|
46
|
+
};
|
|
47
|
+
}
|