@nomideusz/svelte-calendar 0.7.5 → 0.9.0

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 (39) hide show
  1. package/README.md +33 -5
  2. package/dist/calendar/Calendar.svelte +379 -497
  3. package/dist/calendar/Calendar.svelte.d.ts +13 -2
  4. package/dist/core/clock.svelte.d.ts +1 -1
  5. package/dist/core/clock.svelte.js +20 -9
  6. package/dist/core/locale.d.ts +4 -0
  7. package/dist/core/locale.js +4 -0
  8. package/dist/core/timezone.d.ts +12 -0
  9. package/dist/core/timezone.js +31 -0
  10. package/dist/engine/view-state.svelte.d.ts +2 -2
  11. package/dist/engine/view-state.svelte.js +20 -0
  12. package/dist/headless/create-calendar.svelte.js +29 -7
  13. package/dist/headless/types.d.ts +8 -6
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +4 -0
  16. package/dist/primitives/DayHeader.svelte +9 -25
  17. package/dist/primitives/EmptySlot.svelte +15 -31
  18. package/dist/primitives/EventBlock.svelte +33 -61
  19. package/dist/primitives/NowIndicator.svelte +16 -42
  20. package/dist/primitives/TimeGutter.svelte +9 -23
  21. package/dist/views/agenda/Agenda.svelte +3 -10
  22. package/dist/views/agenda/AgendaDay.svelte +195 -167
  23. package/dist/views/agenda/AgendaWeek.svelte +141 -195
  24. package/dist/views/mobile/Mobile.svelte +3 -10
  25. package/dist/views/mobile/MobileDay.svelte +422 -266
  26. package/dist/views/mobile/MobileWeek.svelte +131 -186
  27. package/dist/views/month/MonthGrid.svelte +334 -0
  28. package/dist/views/month/MonthGrid.svelte.d.ts +15 -0
  29. package/dist/views/planner/Planner.svelte +3 -10
  30. package/dist/views/planner/PlannerDay.svelte +636 -546
  31. package/dist/views/planner/PlannerWeek.svelte +299 -412
  32. package/dist/views/shared/EventContent.svelte +16 -0
  33. package/dist/views/shared/EventContent.svelte.d.ts +9 -0
  34. package/dist/views/shared/context.svelte.d.ts +2 -0
  35. package/dist/views/shared/context.svelte.js +2 -0
  36. package/dist/widget/CalendarWidget.svelte +79 -116
  37. package/package.json +3 -2
  38. package/widget/svelte-calendar.css +321 -10
  39. package/widget/widget.js +2508 -1056
@@ -17,495 +17,377 @@
17
17
  oneventcreate={handleCreate}
18
18
  />
19
19
  -->
20
- <script lang="ts">
21
- import { setContext, untrack, type Component, type Snippet } from 'svelte';
22
- import type { CalendarAdapter } from '../adapters/types.js';
23
- import type { CalendarViewId } from '../engine/view-state.svelte.js';
24
- import { createEventStore, type EventStore } from '../engine/event-store.svelte.js';
25
- import { createViewState, type ViewState } from '../engine/view-state.svelte.js';
26
- import { createSelection, type Selection } from '../engine/selection.svelte.js';
27
- import { createDragState, type DragState } from '../engine/drag.svelte.js';
28
- import { onMount } from 'svelte';
29
- import type { TimelineEvent, BlockedSlot } from '../core/types.js';
30
- import { getLabels } from '../core/locale.js';
31
- import { auto } from '../theme/presets.js';
32
- import { probeHostTheme, observeHostTheme } from '../theme/auto.js';
33
- import type { AutoThemeOptions } from '../theme/auto.js';
34
- import Planner from '../views/planner/Planner.svelte';
35
- import Agenda from '../views/agenda/Agenda.svelte';
36
- import Mobile from '../views/mobile/Mobile.svelte';
37
-
38
- /** Breakpoint (px) at which auto-mobile activates */
39
- const MOBILE_BREAKPOINT = 768;
40
-
41
- /** One view registration */
42
- export interface CalendarView {
43
- id: CalendarViewId;
44
- label: string;
45
- /** day or week */
46
- mode: 'day' | 'week';
47
- /** The Svelte component to render */
48
- component: Component<Record<string, unknown>>;
49
- /** Extra props to pass through (e.g. hourHeight, specialized settings) */
50
- props?: Record<string, unknown>;
51
- }
52
-
53
- interface Props {
54
- /** Data adapter (required) */
55
- adapter: CalendarAdapter;
56
- /** Registered views */
57
- views?: CalendarView[];
58
- /** Active view ID (defaults to first registered view) */
59
- view?: CalendarViewId;
60
- /** CSS theme string (--dt-* inline style) */
61
- theme?: string;
62
- /**
63
- * Options for the smart auto-theme.
64
- * When theme is `auto` (empty string), the calendar probes the host page
65
- * and generates matching --dt-* vars automatically.
66
- *
67
- * Pass `{ mode, accent, font }` to override individual aspects.
68
- * Set `autoTheme: false` to disable probing entirely.
69
- */
70
- autoTheme?: AutoThemeOptions | false;
71
- /** Start week on Monday */
72
- mondayStart?: boolean;
73
- /** Total height. Use `'auto'` to let content determine height (ideal for Agenda views). */
74
- height?: number | 'auto';
75
- /** Border radius in px (default: 12). Set to 0 for no rounding. */
76
- borderRadius?: number;
77
- /** Text direction: 'ltr' (default), 'rtl', or 'auto' */
78
- dir?: 'ltr' | 'rtl' | 'auto';
79
- /** BCP 47 locale tag (e.g. 'en-US', 'ar-SA') — sets lang and locale for formatting */
80
- locale?: string;
81
- /** Read-only mode: disables drag, resize, empty-slot creation */
82
- readOnly?: boolean;
83
- /** Visible hour range: [startHour, endHour). Crops the grid to these hours. */
84
- visibleHours?: [number, number];
85
- /** Initial date to focus on (defaults to today) */
86
- initialDate?: Date;
87
- /** Drag snap interval in minutes (default: 15) */
88
- snapInterval?: number;
89
- /** Show the Day/Week mode pills (default: true) */
90
- showModePills?: boolean;
91
- /** Show prev/next/today navigation controls (default: true) */
92
- showNavigation?: boolean;
93
- /** Treat all days equally — no past-day dimming or collapsing (default: false) */
94
- equalDays?: boolean;
95
- /** Hide date numbers — headers show only day names (Mon, Tue, …). Useful for template/recurring schedules. */
96
- showDates?: boolean;
97
- /** ISO weekdays to hide (1=Mon … 7=Sun). E.g. [6, 7] hides weekends. */
98
- hideDays?: number[];
99
- /** Controlled current date drives which date the calendar focuses on. */
100
- currentDate?: Date;
101
- /** Blocked/unavailable time slots rendered as hatched regions, prevent event creation. */
102
- blockedSlots?: BlockedSlot[];
103
- /** Number of days shown in week mode (default: 7). E.g. 3 for a 3-day view, 5 for workweek. */
104
- days?: number;
105
- /** Minimum event duration in minutes (enforced during drag-create and click-to-create). */
106
- minDuration?: number;
107
- /** Maximum event duration in minutes (enforced during drag-create). */
108
- maxDuration?: number;
109
- /** Specific dates to disable (greyed-out, no event creation). */
110
- disabledDates?: Date[];
111
- /** Compact mode: use minimal text-row rendering in Agenda views (dot + time + title). */
112
- compact?: boolean;
113
- /**
114
- * Mobile mode.
115
- * - `'auto'` (default): detect via viewport width (< 768 px)
116
- * - `true`: always use mobile views
117
- * - `false`: never use mobile views
118
- */
119
- mobile?: 'auto' | boolean;
120
-
121
- // ── Snippets ──
122
- /** Custom event rendering snippet */
123
- event?: Snippet<[TimelineEvent]>;
124
- /** Content to show when no events are loaded */
125
- empty?: Snippet;
126
- /** Custom day header snippet. Receives { date, isToday, dayName }. */
127
- dayHeader?: Snippet<[{ date: Date; isToday: boolean; dayName: string }]>;
128
- /**
129
- * Replace the entire header chrome (date label + mode pills + nav arrows).
130
- * Receives context: { dateLabel, mode, modes, switchMode, prev, next, goToday, isViewOnToday, focusDate }.
131
- */
132
- header?: Snippet<[import('../headless/types.js').HeaderContext]>;
133
- /**
134
- * Replace just the navigation controls (arrows + today button).
135
- * Receives context: { prev, next, goToday, isViewOnToday, focusDate, mode }.
136
- */
137
- navigation?: Snippet<[import('../headless/types.js').NavigationContext]>;
138
-
139
- // ── Callbacks ──
140
- oneventclick?: (event: TimelineEvent) => void;
141
- oneventcreate?: (range: { start: Date; end: Date }) => void;
142
- oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
143
- onviewchange?: (viewId: CalendarViewId) => void;
144
- /** Called when the focused date changes (navigation, drag-scroll, etc.) */
145
- ondatechange?: (date: Date) => void;
146
- /** Called when the pointer enters an event (hover). */
147
- oneventhover?: (event: TimelineEvent) => void;
148
- }
149
-
150
- // ── Built-in views (used when no custom views are provided) ──
151
- const DEFAULT_VIEWS: CalendarView[] = [
152
- { id: 'day-planner', label: 'Planner', mode: 'day', component: Planner },
153
- { id: 'week-planner', label: 'Planner', mode: 'week', component: Planner },
154
- { id: 'day-agenda', label: 'Agenda', mode: 'day', component: Agenda },
155
- { id: 'week-agenda', label: 'Agenda', mode: 'week', component: Agenda },
156
- { id: 'day-mobile', label: 'Mobile', mode: 'day', component: Mobile },
157
- { id: 'week-mobile', label: 'Mobile', mode: 'week', component: Mobile },
158
- ];
159
-
160
- let {
161
- adapter,
162
- views = DEFAULT_VIEWS,
163
- view: activeViewId,
164
- theme = auto,
165
- autoTheme,
166
- mondayStart = true,
167
- height: heightProp = 600,
168
- borderRadius = 12,
169
- dir,
170
- locale,
171
- readOnly = false,
172
- visibleHours,
173
- initialDate,
174
- snapInterval = 15,
175
- showModePills = true,
176
- showNavigation = true,
177
- equalDays = false,
178
- showDates = true,
179
- hideDays,
180
- currentDate,
181
- blockedSlots,
182
- days,
183
- minDuration,
184
- maxDuration,
185
- disabledDates,
186
- compact = false,
187
- mobile: mobileProp = 'auto',
188
- event: eventSnippet,
189
- empty: emptySnippet,
190
- dayHeader: dayHeaderSnippet,
191
- header: headerSnippet,
192
- navigation: navigationSnippet,
193
- oneventclick,
194
- oneventcreate,
195
- oneventmove,
196
- onviewchange,
197
- ondatechange,
198
- oneventhover,
199
- }: Props = $props();
200
-
201
- // In readOnly mode, suppress mutation callbacks
202
- const effectiveCreate = $derived(readOnly ? undefined : oneventcreate);
203
- const effectiveMove = $derived(readOnly ? undefined : oneventmove);
204
-
205
- // ── Mobile detection (container-based, not viewport) ──
206
- let containerWidth = $state(0);
207
- const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
208
-
209
- const useMobile = $derived(
210
- mobileProp === 'auto' ? isMobileContainer : Boolean(mobileProp)
211
- );
212
-
213
- // ── Smart auto-theme ──
214
- // When theme is empty (auto preset) and autoTheme is not false,
215
- // probe the host page on mount and reactively watch for host theme changes.
216
- let calEl: HTMLElement | undefined = $state();
217
- let probedTheme = $state('');
218
- // Only delay rendering when auto-probe is actually needed
219
- const needsProbe = $derived(theme === auto && autoTheme !== false);
220
-
221
- onMount(() => {
222
- if (!calEl) return;
223
-
224
- // Measure container width for mobile detection
225
- containerWidth = calEl.clientWidth;
226
- const ro = new ResizeObserver((entries) => {
227
- containerWidth = Math.round(entries[0].contentRect.width);
228
- });
229
- ro.observe(calEl);
230
-
231
- // Only probe theme when using the auto preset
232
- if (!needsProbe) return () => ro.disconnect();
233
-
234
- const opts: AutoThemeOptions = typeof autoTheme === 'object' ? autoTheme : {};
235
- const stopTheme = observeHostTheme(calEl, (vars) => {
236
- probedTheme = vars;
237
- }, opts);
238
- return () => { ro.disconnect(); stopTheme?.(); };
239
- });
240
-
241
- /** Effective theme: user-provided takes priority, otherwise probed auto. */
242
- const effectiveTheme = $derived(theme === auto && autoTheme !== false ? probedTheme : theme);
243
-
244
- // ── Create reactive state ──
245
- const store: EventStore = $derived(createEventStore(adapter));
246
- const viewState: ViewState = createViewState(untrack(() => ({
247
- view: activeViewId ?? views[0]?.id,
248
- mondayStart,
249
- initialDate,
250
- dayCount: days,
251
- modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode,
252
- })));
253
- const selection: Selection = createSelection();
254
- const drag: DragState = createDragState();
255
-
256
- // ── Drag commit handler ──
257
- // Views call this on pointer-up to process drag results.
258
- async function commitDrag(): Promise<void> {
259
- if (readOnly) { drag.cancel(); return; }
260
- const mode = drag.mode;
261
- const payload = drag.commit();
262
- if (!payload) return;
263
-
264
- let { start, end } = payload;
265
-
266
- // Enforce min/max duration for create and resize
267
- if (mode === 'create' || mode === 'resize-start' || mode === 'resize-end') {
268
- const durationMs = end.getTime() - start.getTime();
269
- const durationMin = durationMs / 60_000;
270
- if (minDuration && durationMin < minDuration) {
271
- if (mode === 'resize-start') {
272
- start = new Date(end.getTime() - minDuration * 60_000);
273
- } else {
274
- end = new Date(start.getTime() + minDuration * 60_000);
275
- }
276
- }
277
- if (maxDuration && durationMin > maxDuration) {
278
- if (mode === 'resize-start') {
279
- start = new Date(end.getTime() - maxDuration * 60_000);
280
- } else {
281
- end = new Date(start.getTime() + maxDuration * 60_000);
282
- }
283
- }
284
- }
285
-
286
- // Reject if target lands on a disabled date
287
- if (disabledDates?.length) {
288
- const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
289
- const endDay = new Date(end.getTime() - 1); endDay.setHours(0, 0, 0, 0);
290
- for (const dd of disabledDates) {
291
- const dt = new Date(dd); dt.setHours(0, 0, 0, 0);
292
- const ts = dt.getTime();
293
- if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
294
- }
295
- }
296
-
297
- // Reject if target overlaps a blocked slot
298
- if (blockedSlots?.length) {
299
- const startH = start.getHours() + start.getMinutes() / 60;
300
- const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
301
- const jsDay = start.getDay();
302
- const isoDay = jsDay === 0 ? 7 : jsDay;
303
- for (const slot of blockedSlots) {
304
- if (slot.day && slot.day !== isoDay) continue;
305
- if (startH < slot.end && endH > slot.start) return;
306
- }
307
- }
308
-
309
- if ((mode === 'move' || mode === 'resize-start' || mode === 'resize-end') && payload.eventId) {
310
- try {
311
- await store.move(payload.eventId, start, end);
312
- const ev = store.byId(payload.eventId);
313
- if (ev) effectiveMove?.(ev, start, end);
314
- } catch (e) {
315
- // Silently handle read-only / missing event errors.
316
- // The optimistic update in store.move() already reverted.
317
- const msg = e instanceof Error ? e.message : '';
318
- if (!msg.includes('read-only') && !msg.includes('not found')) {
319
- console.warn('[calendar] drag commit failed:', e);
320
- }
321
- }
322
- } else if (mode === 'create') {
323
- effectiveCreate?.({ start, end });
324
- }
325
- }
326
-
327
- // ── Load range signal ──
328
- // Views can write a wider range here to override the default viewState.range.
329
- // This lets infinite-scroll views (PlannerWeek, PlannerDay) declare their
330
- // buffer needs without directly calling store.load().
331
- let viewLoadRange = $state<{ start: Date; end: Date } | null>(null);
332
-
333
- // ── Single context object ──
334
- // All view state is exposed through one context key with reactive getters.
335
- // Views read this via useCalendarContext() from views/shared/context.svelte.ts.
336
- setContext('calendar', {
337
- // Engine objects (hold $state internally)
338
- get store() { return store; },
339
- viewState,
340
- selection,
341
- drag,
342
- commitDrag,
343
-
344
- // Callbacks
345
- get oneventclick() { return oneventclick; },
346
- get oneventcreate() { return effectiveCreate; },
347
- get oneventmove() { return effectiveMove; },
348
- get oneventhover() { return oneventhover; },
349
-
350
- // Config (reactive via getters)
351
- get readOnly() { return readOnly; },
352
- get visibleHours() { return visibleHours; },
353
- get snapInterval() { return snapInterval; },
354
- get eventSnippet() { return eventSnippet; },
355
- get emptySnippet() { return emptySnippet; },
356
- get equalDays() { return equalDays; },
357
- get showDates() { return showDates; },
358
- get hideDays() { return hideDays; },
359
- get blockedSlots() { return blockedSlots; },
360
- get dayHeaderSnippet() { return dayHeaderSnippet; },
361
- get minDuration() { return minDuration; },
362
- get maxDuration() { return maxDuration; },
363
- get disabledDates() { return disabledDates; },
364
- get mobile() { return useMobile; },
365
- get autoHeight() { return heightProp === 'auto'; },
366
- get compact() { return compact; },
367
-
368
- // Load range (read/write)
369
- get loadRange() { return viewLoadRange; },
370
- setLoadRange(range: { start: Date; end: Date } | null) { viewLoadRange = range; },
371
- });
372
-
373
- // ── Load events when effective range changes ──
374
- $effect(() => {
375
- const range = viewLoadRange ?? viewState.range;
376
- store.load({ start: range.start, end: range.end });
377
- });
378
- // Eager initial load — $effect runs after paint, but for sync adapters
379
- // (memory, recurring) this resolves in the same microtask.
380
- untrack(() => store.load({ start: viewState.range.start, end: viewState.range.end }));
381
-
382
- // Keep active view in sync when external view prop changes after mount.
383
- $effect(() => {
384
- if (activeViewId) viewState.setView(activeViewId);
385
- });
386
-
387
- // Sync controlled currentDate prop → viewState
388
- $effect(() => {
389
- if (currentDate) viewState.setFocusDate(currentDate);
390
- });
391
-
392
- // Sync days prop → viewState.dayCount
393
- $effect(() => {
394
- if (days !== undefined && viewState.dayCount !== days) viewState.setDayCount(days);
395
- });
396
-
397
- // Notify host when focusDate changes
398
- $effect(() => {
399
- const d = viewState.focusDate;
400
- ondatechange?.(d);
401
- });
402
-
403
- // Keep view state's week-start rule in sync with incoming prop changes.
404
- $effect(() => {
405
- if (viewState.mondayStart !== mondayStart) {
406
- viewState.setMondayStart(mondayStart);
407
- }
408
- });
409
-
410
- // Notify host when active view changes (e.g. via mode toggles).
411
- $effect(() => {
412
- onviewchange?.(viewState.view);
413
- });
414
-
415
-
416
- // ── Resolve active view ──
417
- // When mobile is active, Planner views get remapped to Mobile variants.
418
- // Agenda views stay as Agenda — they're already list-based and will adapt
419
- // via the 'calendar:mobile' context flag.
420
- const resolvedView = $derived.by(() => {
421
- const requested = views.find((v) => v.id === viewState.view) ?? views[0];
422
- if (!useMobile || !requested) return requested;
423
- // Already a mobile view? Keep it.
424
- if (requested.id.endsWith('-mobile')) return requested;
425
- // Agenda views: keep as-is (they adapt internally via mobile context).
426
- if (requested.label === 'Agenda') return requested;
427
- // Planner / other views: remap to mobile variant with the same mode.
428
- const mobileVariant = views.find(
429
- (v) => v.id === `${requested.mode}-mobile`
430
- );
431
- return mobileVariant ?? requested;
432
- });
433
-
434
- // Backward-compat alias used in the template.
435
- const activeView = $derived(resolvedView);
436
-
437
- // Non-mobile views for mode pills (exclude mobile entries).
438
- const desktopViews = $derived(views.filter((v) => !v.id.endsWith('-mobile')));
439
-
440
- // ── Date label (always visible, centered over views) ──
441
- const dateLabel = $derived.by(() => {
442
- if (!showDates) {
443
- // Template week mode: just show weekday name (day) or nothing (week)
444
- if (viewState.mode === 'day') {
445
- return viewState.focusDate.toLocaleDateString(locale, { weekday: 'long' });
446
- }
447
- return ''; // week views have their own day headers
448
- }
449
- if (viewState.mode === 'day') {
450
- return viewState.focusDate.toLocaleDateString(locale, {
451
- weekday: 'long',
452
- month: 'short',
453
- day: 'numeric',
454
- });
455
- }
456
- return viewState.focusDate.toLocaleDateString(locale, {
457
- month: 'long',
458
- year: 'numeric',
459
- });
460
- });
461
-
462
- // Which modes are available?
463
- const modes = $derived.by(() => {
464
- const g = new Set(desktopViews.map((v) => v.mode));
465
- return (['day', 'week'] as const).filter((key) => g.has(key));
466
- });
467
-
468
- const L = $derived(getLabels());
469
-
470
- /** Switch to a different mode (day/week), preserving the current view label. */
471
- function switchMode(g: 'day' | 'week') {
472
- const currentLabel = desktopViews.find((v) => v.id === viewState.view)?.label
473
- ?? activeView?.label;
474
- const match = desktopViews.find((v) => v.mode === g && v.label === currentLabel);
475
- const fallback = desktopViews.find((v) => v.mode === g);
476
- const target = match ?? fallback;
477
- if (target) viewState.setView(target.id);
478
- }
479
-
480
- /** True when the current view range already includes today. */
481
- const viewIncludesToday = $derived.by(() => {
482
- const now = Date.now();
483
- const { start, end } = viewState.range;
484
- return now >= start.getTime() && now < end.getTime();
485
- });
486
-
487
- /** Header context for custom header snippet */
488
- const headerCtx = $derived({
489
- dateLabel,
490
- mode: viewState.mode,
491
- modes,
492
- switchMode,
493
- prev: () => viewState.prev(),
494
- next: () => viewState.next(),
495
- goToday: () => viewState.goToday(),
496
- isViewOnToday: viewIncludesToday,
497
- focusDate: viewState.focusDate,
498
- });
499
-
500
- /** Navigation context for custom navigation snippet */
501
- const navCtx = $derived({
502
- prev: () => viewState.prev(),
503
- next: () => viewState.next(),
504
- goToday: () => viewState.goToday(),
505
- isViewOnToday: viewIncludesToday,
506
- focusDate: viewState.focusDate,
507
- mode: viewState.mode,
508
- });
20
+ <script lang="ts">import { setContext, untrack } from "svelte";
21
+ import { createEventStore } from "../engine/event-store.svelte.js";
22
+ import { createViewState } from "../engine/view-state.svelte.js";
23
+ import { createSelection } from "../engine/selection.svelte.js";
24
+ import { createDragState } from "../engine/drag.svelte.js";
25
+ import { onMount } from "svelte";
26
+ import { getLabels } from "../core/locale.js";
27
+ import { auto } from "../theme/presets.js";
28
+ import { probeHostTheme, observeHostTheme } from "../theme/auto.js";
29
+ import Planner from "../views/planner/Planner.svelte";
30
+ import Agenda from "../views/agenda/Agenda.svelte";
31
+ import Mobile from "../views/mobile/Mobile.svelte";
32
+ import MonthGrid from "../views/month/MonthGrid.svelte";
33
+ import { wrapAdapterWithTimezone, toZonedTime, fromZonedTime } from "../core/timezone.js";
34
+ const MOBILE_BREAKPOINT = 768;
35
+ const DEFAULT_VIEWS = [
36
+ { id: "day-planner", label: "Planner", mode: "day", component: Planner },
37
+ { id: "week-planner", label: "Planner", mode: "week", component: Planner },
38
+ { id: "day-agenda", label: "Agenda", mode: "day", component: Agenda },
39
+ { id: "week-agenda", label: "Agenda", mode: "week", component: Agenda },
40
+ { id: "day-mobile", label: "Mobile", mode: "day", component: Mobile },
41
+ { id: "week-mobile", label: "Mobile", mode: "week", component: Mobile },
42
+ { id: "month-grid", label: "Month", mode: "month", component: MonthGrid }
43
+ ];
44
+ let {
45
+ adapter,
46
+ views = DEFAULT_VIEWS,
47
+ view: activeViewId,
48
+ theme = auto,
49
+ autoTheme,
50
+ mondayStart = true,
51
+ height: heightProp = 600,
52
+ borderRadius = 12,
53
+ dir,
54
+ locale,
55
+ readOnly = false,
56
+ visibleHours,
57
+ initialDate,
58
+ snapInterval = 15,
59
+ showModePills = true,
60
+ showNavigation = true,
61
+ equalDays = false,
62
+ showDates = true,
63
+ hideDays,
64
+ currentDate,
65
+ blockedSlots,
66
+ days,
67
+ minDuration,
68
+ maxDuration,
69
+ disabledDates,
70
+ compact = false,
71
+ mobile: mobileProp = "auto",
72
+ event: eventSnippet,
73
+ empty: emptySnippet,
74
+ dayHeader: dayHeaderSnippet,
75
+ header: headerSnippet,
76
+ navigation: navigationSnippet,
77
+ oneventclick,
78
+ oneventcreate,
79
+ oneventmove,
80
+ onviewchange,
81
+ ondatechange,
82
+ oneventhover,
83
+ ondayclick,
84
+ onerror,
85
+ timezone
86
+ } = $props();
87
+ const unzone = (d) => timezone ? fromZonedTime(d, timezone) : d;
88
+ const effectiveCreate = $derived(
89
+ readOnly || !oneventcreate ? void 0 : (range) => oneventcreate({ start: unzone(range.start), end: unzone(range.end) })
90
+ );
91
+ const effectiveMove = $derived(
92
+ readOnly || !oneventmove ? void 0 : (ev, start, end) => oneventmove(ev, unzone(start), unzone(end))
93
+ );
94
+ function handleEventClick(ev) {
95
+ selection.select(ev.id);
96
+ oneventclick?.(ev);
97
+ }
98
+ let containerWidth = $state(0);
99
+ const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
100
+ const useMobile = $derived(
101
+ mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp)
102
+ );
103
+ let calEl = $state();
104
+ let probedTheme = $state("");
105
+ const needsProbe = $derived(theme === auto && autoTheme !== false);
106
+ onMount(() => {
107
+ if (!calEl) return;
108
+ containerWidth = calEl.clientWidth;
109
+ const ro = new ResizeObserver((entries) => {
110
+ containerWidth = Math.round(entries[0].contentRect.width);
111
+ });
112
+ ro.observe(calEl);
113
+ if (!needsProbe) return () => ro.disconnect();
114
+ const opts = typeof autoTheme === "object" ? autoTheme : {};
115
+ const stopTheme = observeHostTheme(calEl, (vars) => {
116
+ probedTheme = vars;
117
+ }, opts);
118
+ return () => {
119
+ ro.disconnect();
120
+ stopTheme?.();
121
+ };
122
+ });
123
+ const effectiveTheme = $derived(theme === auto && autoTheme !== false ? probedTheme : theme);
124
+ const effectiveAdapter = $derived(
125
+ timezone ? wrapAdapterWithTimezone(adapter, timezone) : adapter
126
+ );
127
+ const store = $derived(createEventStore(effectiveAdapter));
128
+ const viewState = createViewState(untrack(() => ({
129
+ view: activeViewId ?? views[0]?.id,
130
+ mondayStart,
131
+ // Focus lives on the zoned plane too — day boundaries follow the zone.
132
+ initialDate: initialDate && timezone ? toZonedTime(initialDate, timezone) : initialDate,
133
+ dayCount: days,
134
+ timezone,
135
+ modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode
136
+ })));
137
+ const selection = createSelection();
138
+ const drag = createDragState();
139
+ async function commitDrag() {
140
+ if (readOnly) {
141
+ drag.cancel();
142
+ return;
143
+ }
144
+ const mode = drag.mode;
145
+ const payload = drag.commit();
146
+ if (!payload) return;
147
+ let { start, end } = payload;
148
+ if (mode === "create" || mode === "resize-start" || mode === "resize-end") {
149
+ if (end.getTime() <= start.getTime()) {
150
+ const floorMs = Math.max(1, snapInterval) * 6e4;
151
+ if (mode === "resize-start") start = new Date(end.getTime() - floorMs);
152
+ else end = new Date(start.getTime() + floorMs);
153
+ }
154
+ const durationMs = end.getTime() - start.getTime();
155
+ const durationMin = durationMs / 6e4;
156
+ if (minDuration && durationMin < minDuration) {
157
+ if (mode === "resize-start") {
158
+ start = new Date(end.getTime() - minDuration * 6e4);
159
+ } else {
160
+ end = new Date(start.getTime() + minDuration * 6e4);
161
+ }
162
+ }
163
+ if (maxDuration && durationMin > maxDuration) {
164
+ if (mode === "resize-start") {
165
+ start = new Date(end.getTime() - maxDuration * 6e4);
166
+ } else {
167
+ end = new Date(start.getTime() + maxDuration * 6e4);
168
+ }
169
+ }
170
+ }
171
+ if (disabledDates?.length) {
172
+ const startDay = new Date(start);
173
+ startDay.setHours(0, 0, 0, 0);
174
+ const endDay = new Date(end.getTime() - 1);
175
+ endDay.setHours(0, 0, 0, 0);
176
+ for (const dd of disabledDates) {
177
+ const dt = new Date(dd);
178
+ dt.setHours(0, 0, 0, 0);
179
+ const ts = dt.getTime();
180
+ if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
181
+ }
182
+ }
183
+ if (blockedSlots?.length) {
184
+ const startH = start.getHours() + start.getMinutes() / 60;
185
+ const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
186
+ const jsDay = start.getDay();
187
+ const isoDay = jsDay === 0 ? 7 : jsDay;
188
+ for (const slot of blockedSlots) {
189
+ if (slot.day && slot.day !== isoDay) continue;
190
+ if (startH < slot.end && endH > slot.start) return;
191
+ }
192
+ }
193
+ if ((mode === "move" || mode === "resize-start" || mode === "resize-end") && payload.eventId) {
194
+ try {
195
+ await store.move(payload.eventId, start, end);
196
+ const ev = store.byId(payload.eventId);
197
+ if (ev) effectiveMove?.(ev, start, end);
198
+ } catch (e) {
199
+ const msg = e instanceof Error ? e.message : "";
200
+ if (msg.includes("read-only")) {
201
+ const ev = store.byId(payload.eventId);
202
+ if (ev) effectiveMove?.(ev, start, end);
203
+ } else if (!msg.includes("not found")) {
204
+ if (onerror) onerror(e instanceof Error ? e : new Error(String(e)));
205
+ else console.warn("[calendar] drag commit failed:", e);
206
+ }
207
+ }
208
+ } else if (mode === "create") {
209
+ effectiveCreate?.({ start, end });
210
+ }
211
+ }
212
+ let viewLoadRange = $state(null);
213
+ setContext("calendar", {
214
+ // Engine objects (hold $state internally)
215
+ get store() {
216
+ return store;
217
+ },
218
+ viewState,
219
+ selection,
220
+ drag,
221
+ commitDrag,
222
+ // Callbacks
223
+ get oneventclick() {
224
+ return handleEventClick;
225
+ },
226
+ get oneventcreate() {
227
+ return effectiveCreate;
228
+ },
229
+ get oneventmove() {
230
+ return effectiveMove;
231
+ },
232
+ get oneventhover() {
233
+ return oneventhover;
234
+ },
235
+ get ondayclick() {
236
+ return ondayclick;
237
+ },
238
+ get timezone() {
239
+ return timezone;
240
+ },
241
+ // Config (reactive via getters)
242
+ get readOnly() {
243
+ return readOnly;
244
+ },
245
+ get visibleHours() {
246
+ return visibleHours;
247
+ },
248
+ get snapInterval() {
249
+ return snapInterval;
250
+ },
251
+ get eventSnippet() {
252
+ return eventSnippet;
253
+ },
254
+ get emptySnippet() {
255
+ return emptySnippet;
256
+ },
257
+ get equalDays() {
258
+ return equalDays;
259
+ },
260
+ get showDates() {
261
+ return showDates;
262
+ },
263
+ get hideDays() {
264
+ return hideDays;
265
+ },
266
+ get blockedSlots() {
267
+ return blockedSlots;
268
+ },
269
+ get dayHeaderSnippet() {
270
+ return dayHeaderSnippet;
271
+ },
272
+ get minDuration() {
273
+ return minDuration;
274
+ },
275
+ get maxDuration() {
276
+ return maxDuration;
277
+ },
278
+ get disabledDates() {
279
+ return disabledDates;
280
+ },
281
+ get mobile() {
282
+ return useMobile;
283
+ },
284
+ get autoHeight() {
285
+ return heightProp === "auto";
286
+ },
287
+ get compact() {
288
+ return compact;
289
+ },
290
+ // Load range (read/write)
291
+ get loadRange() {
292
+ return viewLoadRange;
293
+ },
294
+ setLoadRange(range) {
295
+ viewLoadRange = range;
296
+ }
297
+ });
298
+ $effect(() => {
299
+ const range = viewLoadRange ?? viewState.range;
300
+ store.load({ start: range.start, end: range.end });
301
+ });
302
+ untrack(() => store.load({ start: viewState.range.start, end: viewState.range.end }));
303
+ $effect(() => {
304
+ if (activeViewId) viewState.setView(activeViewId);
305
+ });
306
+ $effect(() => {
307
+ if (currentDate) viewState.setFocusDate(currentDate);
308
+ });
309
+ $effect(() => {
310
+ if (days !== void 0 && viewState.dayCount !== days) viewState.setDayCount(days);
311
+ });
312
+ $effect(() => {
313
+ const d = viewState.focusDate;
314
+ ondatechange?.(d);
315
+ });
316
+ $effect(() => {
317
+ if (viewState.mondayStart !== mondayStart) {
318
+ viewState.setMondayStart(mondayStart);
319
+ }
320
+ });
321
+ $effect(() => {
322
+ onviewchange?.(viewState.view);
323
+ });
324
+ $effect(() => {
325
+ if (store.error && onerror) onerror(new Error(store.error));
326
+ });
327
+ const resolvedView = $derived.by(() => {
328
+ const requested = views.find((v) => v.id === viewState.view) ?? views[0];
329
+ if (!useMobile || !requested) return requested;
330
+ if (requested.id.endsWith("-mobile")) return requested;
331
+ if (requested.label === "Agenda") return requested;
332
+ const mobileVariant = views.find(
333
+ (v) => v.id === `${requested.mode}-mobile`
334
+ );
335
+ return mobileVariant ?? requested;
336
+ });
337
+ const activeView = $derived(resolvedView);
338
+ const desktopViews = $derived(views.filter((v) => !v.id.endsWith("-mobile")));
339
+ const dateLabel = $derived.by(() => {
340
+ if (!showDates) {
341
+ return "";
342
+ }
343
+ if (viewState.mode === "day") {
344
+ return viewState.focusDate.toLocaleDateString(locale, {
345
+ weekday: "long",
346
+ month: "short",
347
+ day: "numeric"
348
+ });
349
+ }
350
+ return viewState.focusDate.toLocaleDateString(locale, {
351
+ month: "long",
352
+ year: "numeric"
353
+ });
354
+ });
355
+ const modes = $derived.by(() => {
356
+ const g = new Set(desktopViews.map((v) => v.mode));
357
+ return ["day", "week", "month"].filter((key) => g.has(key));
358
+ });
359
+ const L = $derived(getLabels());
360
+ function switchMode(g) {
361
+ const currentLabel = desktopViews.find((v) => v.id === viewState.view)?.label ?? activeView?.label;
362
+ const match = desktopViews.find((v) => v.mode === g && v.label === currentLabel);
363
+ const fallback = desktopViews.find((v) => v.mode === g);
364
+ const target = match ?? fallback;
365
+ if (target) viewState.setView(target.id);
366
+ }
367
+ const viewIncludesToday = $derived.by(() => {
368
+ const now = Date.now();
369
+ const { start, end } = viewState.range;
370
+ return now >= start.getTime() && now < end.getTime();
371
+ });
372
+ const headerCtx = $derived({
373
+ dateLabel,
374
+ mode: viewState.mode,
375
+ modes,
376
+ switchMode,
377
+ prev: () => viewState.prev(),
378
+ next: () => viewState.next(),
379
+ goToday: () => viewState.goToday(),
380
+ isViewOnToday: viewIncludesToday,
381
+ focusDate: viewState.focusDate
382
+ });
383
+ const navCtx = $derived({
384
+ prev: () => viewState.prev(),
385
+ next: () => viewState.next(),
386
+ goToday: () => viewState.goToday(),
387
+ isViewOnToday: viewIncludesToday,
388
+ focusDate: viewState.focusDate,
389
+ mode: viewState.mode
390
+ });
509
391
  </script>
510
392
 
511
393
  <div
@@ -523,13 +405,13 @@
523
405
  {@render headerSnippet(headerCtx)}
524
406
 
525
407
  <!-- ─── Mobile header (flow layout, no absolute) ─── -->
526
- {:else if useMobile}
408
+ {:else if useMobile && (showNavigation || (showModePills && modes.length > 1) || dateLabel)}
527
409
  <div class="cal-m-hd">
528
410
  <div class="cal-m-left">
529
411
  {#if navigationSnippet}
530
412
  {@render navigationSnippet(navCtx)}
531
413
  {:else if showNavigation}
532
- <button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : L.previousWeek}>
414
+ <button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
533
415
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>
534
416
  </button>
535
417
  {/if}
@@ -543,7 +425,7 @@
543
425
  aria-pressed={viewState.mode === g}
544
426
  onclick={() => switchMode(g)}
545
427
  >
546
- {g === 'day' ? L.day : L.week}
428
+ {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
547
429
  </button>
548
430
  {/each}
549
431
  </div>
@@ -554,7 +436,7 @@
554
436
 
555
437
  <div class="cal-m-right">
556
438
  {#if !navigationSnippet && showNavigation}
557
- <button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : L.nextWeek}>
439
+ <button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
558
440
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
559
441
  </button>
560
442
  {/if}
@@ -582,10 +464,10 @@
582
464
  {L.today}
583
465
  </button>
584
466
  {/if}
585
- <button class="cal-hd-btn" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : L.previousWeek}>
467
+ <button class="cal-hd-btn" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
586
468
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>
587
469
  </button>
588
- <button class="cal-hd-btn" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : L.nextWeek}>
470
+ <button class="cal-hd-btn" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
589
471
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
590
472
  </button>
591
473
  {/if}
@@ -601,7 +483,7 @@
601
483
  aria-pressed={viewState.mode === g}
602
484
  onclick={() => switchMode(g)}
603
485
  >
604
- {g === 'day' ? L.day : L.week}
486
+ {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
605
487
  </button>
606
488
  {/each}
607
489
  </div>
@@ -621,7 +503,7 @@
621
503
  mondayStart={viewState.mondayStart}
622
504
  {locale}
623
505
  focusDate={viewState.focusDate}
624
- oneventclick={oneventclick}
506
+ oneventclick={handleEventClick}
625
507
  oneventcreate={effectiveCreate}
626
508
  readOnly={readOnly}
627
509
  visibleHours={visibleHours}