@nomideusz/svelte-calendar 0.6.0 → 0.6.3

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.
@@ -1,767 +1,776 @@
1
- <!--
2
- Calendar — the unified orchestrator.
3
-
4
- Brings together: adapter → event store → view state + selection → active view.
5
- Provides context so any descendant view can read the store/state via getContext().
6
-
7
- Usage (minimal):
8
- <Calendar adapter={myAdapter} theme={neutral} />
9
-
10
- Usage (full control):
11
- <Calendar
12
- adapter={myAdapter}
13
- view="week-planner"
14
- theme={midnight}
15
- height={600}
16
- oneventclick={handleClick}
17
- oneventcreate={handleCreate}
18
- />
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 */
74
- height?: number;
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
- /**
112
- * Mobile mode.
113
- * - `'auto'` (default): detect via viewport width (< 768 px)
114
- * - `true`: always use mobile views
115
- * - `false`: never use mobile views
116
- */
117
- mobile?: 'auto' | boolean;
118
-
119
- // ── Snippets ──
120
- /** Custom event rendering snippet */
121
- event?: Snippet<[TimelineEvent]>;
122
- /** Content to show when no events are loaded */
123
- empty?: Snippet;
124
- /** Custom day header snippet. Receives { date, isToday, dayName }. */
125
- dayHeader?: Snippet<[{ date: Date; isToday: boolean; dayName: string }]>;
126
-
127
- // ── Callbacks ──
128
- oneventclick?: (event: TimelineEvent) => void;
129
- oneventcreate?: (range: { start: Date; end: Date }) => void;
130
- oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
131
- onviewchange?: (viewId: CalendarViewId) => void;
132
- /** Called when the focused date changes (navigation, drag-scroll, etc.) */
133
- ondatechange?: (date: Date) => void;
134
- /** Called when the pointer enters an event (hover). */
135
- oneventhover?: (event: TimelineEvent) => void;
136
- }
137
-
138
- // ── Built-in views (used when no custom views are provided) ──
139
- const DEFAULT_VIEWS: CalendarView[] = [
140
- { id: 'day-planner', label: 'Planner', mode: 'day', component: Planner },
141
- { id: 'week-planner', label: 'Planner', mode: 'week', component: Planner },
142
- { id: 'day-agenda', label: 'Agenda', mode: 'day', component: Agenda },
143
- { id: 'week-agenda', label: 'Agenda', mode: 'week', component: Agenda },
144
- { id: 'day-mobile', label: 'Mobile', mode: 'day', component: Mobile },
145
- { id: 'week-mobile', label: 'Mobile', mode: 'week', component: Mobile },
146
- ];
147
-
148
- let {
149
- adapter,
150
- views = DEFAULT_VIEWS,
151
- view: activeViewId,
152
- theme = auto,
153
- autoTheme,
154
- mondayStart = true,
155
- height = 600,
156
- borderRadius = 12,
157
- dir,
158
- locale,
159
- readOnly = false,
160
- visibleHours,
161
- initialDate,
162
- snapInterval = 15,
163
- showModePills = true,
164
- showNavigation = true,
165
- equalDays = false,
166
- showDates = true,
167
- hideDays,
168
- currentDate,
169
- blockedSlots,
170
- days,
171
- minDuration,
172
- maxDuration,
173
- disabledDates,
174
- mobile: mobileProp = 'auto',
175
- event: eventSnippet,
176
- empty: emptySnippet,
177
- dayHeader: dayHeaderSnippet,
178
- oneventclick,
179
- oneventcreate,
180
- oneventmove,
181
- onviewchange,
182
- ondatechange,
183
- oneventhover,
184
- }: Props = $props();
185
-
186
- // In readOnly mode, suppress mutation callbacks
187
- const effectiveCreate = $derived(readOnly ? undefined : oneventcreate);
188
- const effectiveMove = $derived(readOnly ? undefined : oneventmove);
189
-
190
- // ── Mobile detection ──
191
- let isMobileViewport = $state(false);
192
-
193
- onMount(() => {
194
- if (typeof window === 'undefined') return;
195
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
196
- isMobileViewport = mql.matches;
197
- function onChange(e: MediaQueryListEvent) { isMobileViewport = e.matches; }
198
- mql.addEventListener('change', onChange);
199
- return () => mql.removeEventListener('change', onChange);
200
- });
201
-
202
- const useMobile = $derived(
203
- mobileProp === 'auto' ? isMobileViewport : Boolean(mobileProp)
204
- );
205
-
206
- // ── Smart auto-theme ──
207
- // When theme is empty (auto preset) and autoTheme is not false,
208
- // probe the host page on mount and reactively watch for host theme changes.
209
- let calEl: HTMLElement | undefined = $state();
210
- let probedTheme = $state('');
211
-
212
- onMount(() => {
213
- if (!calEl) return;
214
- // Only probe when using the auto preset (empty string)
215
- const isAuto = theme === auto && autoTheme !== false;
216
- if (!isAuto) return;
217
-
218
- const opts: AutoThemeOptions = typeof autoTheme === 'object' ? autoTheme : {};
219
- return observeHostTheme(calEl, (vars) => { probedTheme = vars; }, opts);
220
- });
221
-
222
- /** Effective theme: user-provided takes priority, otherwise probed auto. */
223
- const effectiveTheme = $derived(theme === auto && autoTheme !== false ? probedTheme : theme);
224
-
225
- // ── Create reactive state ──
226
- const store: EventStore = $derived(createEventStore(adapter));
227
- const viewState: ViewState = createViewState(untrack(() => ({
228
- view: activeViewId ?? views[0]?.id,
229
- mondayStart,
230
- initialDate,
231
- dayCount: days,
232
- modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode,
233
- })));
234
- const selection: Selection = createSelection();
235
- const drag: DragState = createDragState();
236
-
237
- // ── Drag commit handler ──
238
- // Views call this on pointer-up to process drag results.
239
- function commitDrag(): void {
240
- if (readOnly) { drag.cancel(); return; }
241
- const mode = drag.mode;
242
- const payload = drag.commit();
243
- if (!payload) return;
244
-
245
- let { start, end } = payload;
246
-
247
- // Enforce min/max duration for create and resize
248
- if (mode === 'create' || mode === 'resize-start' || mode === 'resize-end') {
249
- const durationMs = end.getTime() - start.getTime();
250
- const durationMin = durationMs / 60_000;
251
- if (minDuration && durationMin < minDuration) {
252
- if (mode === 'resize-start') {
253
- start = new Date(end.getTime() - minDuration * 60_000);
254
- } else {
255
- end = new Date(start.getTime() + minDuration * 60_000);
256
- }
257
- }
258
- if (maxDuration && durationMin > maxDuration) {
259
- if (mode === 'resize-start') {
260
- start = new Date(end.getTime() - maxDuration * 60_000);
261
- } else {
262
- end = new Date(start.getTime() + maxDuration * 60_000);
263
- }
264
- }
265
- }
266
-
267
- // Reject if target lands on a disabled date
268
- if (disabledDates?.length) {
269
- const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
270
- const endDay = new Date(end.getTime() - 1); endDay.setHours(0, 0, 0, 0);
271
- for (const dd of disabledDates) {
272
- const dt = new Date(dd); dt.setHours(0, 0, 0, 0);
273
- const ts = dt.getTime();
274
- if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
275
- }
276
- }
277
-
278
- // Reject if target overlaps a blocked slot
279
- if (blockedSlots?.length) {
280
- const startH = start.getHours() + start.getMinutes() / 60;
281
- const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
282
- const jsDay = start.getDay();
283
- const isoDay = jsDay === 0 ? 7 : jsDay;
284
- for (const slot of blockedSlots) {
285
- if (slot.day && slot.day !== isoDay) continue;
286
- if (startH < slot.end && endH > slot.start) return;
287
- }
288
- }
289
-
290
- if ((mode === 'move' || mode === 'resize-start' || mode === 'resize-end') && payload.eventId) {
291
- store.move(payload.eventId, start, end);
292
- const ev = store.byId(payload.eventId);
293
- if (ev) effectiveMove?.(ev, start, end);
294
- } else if (mode === 'create') {
295
- effectiveCreate?.({ start, end });
296
- }
297
- }
298
-
299
- // Context is set once at mount; engine objects hold $state internally
300
- // so consumers reading from them stay reactive automatically.
301
- // Wrap store in a getter since it's $derived and may change when adapter changes.
302
- setContext('calendar:store', { get current() { return store; } });
303
- setContext('calendar:viewState', viewState);
304
- setContext('calendar:selection', selection);
305
- setContext('calendar:drag', drag);
306
- setContext('calendar:commitDrag', commitDrag);
307
- // Callbacks: provide a getter-based object so context consumers
308
- // always see the latest callback references.
309
- setContext('calendar:callbacks', {
310
- get oneventclick() { return oneventclick; },
311
- get oneventcreate() { return effectiveCreate; },
312
- get oneventmove() { return effectiveMove; },
313
- get oneventhover() { return oneventhover; },
314
- });
315
- setContext('calendar:readOnly', { get current() { return readOnly; } });
316
- setContext('calendar:visibleHours', { get current() { return visibleHours; } });
317
- setContext('calendar:snapInterval', { get current() { return snapInterval; } });
318
- setContext('calendar:eventSnippet', { get current() { return eventSnippet; } });
319
- setContext('calendar:emptySnippet', { get current() { return emptySnippet; } });
320
- setContext('calendar:showNavigation', { get current() { return showNavigation; } });
321
- setContext('calendar:equalDays', { get current() { return equalDays; } });
322
- setContext('calendar:showDates', { get current() { return showDates; } });
323
- setContext('calendar:hideDays', { get current() { return hideDays; } });
324
- setContext('calendar:blockedSlots', { get current() { return blockedSlots; } });
325
- setContext('calendar:dayHeaderSnippet', { get current() { return dayHeaderSnippet; } });
326
- setContext('calendar:minDuration', { get current() { return minDuration; } });
327
- setContext('calendar:maxDuration', { get current() { return maxDuration; } });
328
- setContext('calendar:disabledDates', { get current() { return disabledDates; } });
329
- setContext('calendar:mobile', { get current() { return useMobile; } });
330
-
331
- // ── Load range signal ──
332
- // Views can write a wider range here to override the default viewState.range.
333
- // This lets infinite-scroll views (PlannerWeek, PlannerDay) declare their
334
- // buffer needs without directly calling store.load().
335
- let viewLoadRange = $state<{ start: Date; end: Date } | null>(null);
336
- setContext('calendar:loadRange', {
337
- get current() { return viewLoadRange; },
338
- set(range: { start: Date; end: Date } | null) { viewLoadRange = range; },
339
- });
340
-
341
- // ── Load events when effective range changes ──
342
- $effect(() => {
343
- const range = viewLoadRange ?? viewState.range;
344
- store.load({ start: range.start, end: range.end });
345
- });
346
-
347
- // Keep active view in sync when external view prop changes after mount.
348
- $effect(() => {
349
- if (activeViewId) viewState.setView(activeViewId);
350
- });
351
-
352
- // Sync controlled currentDate prop → viewState
353
- $effect(() => {
354
- if (currentDate) viewState.setFocusDate(currentDate);
355
- });
356
-
357
- // Sync days prop → viewState.dayCount
358
- $effect(() => {
359
- if (days !== undefined && viewState.dayCount !== days) viewState.setDayCount(days);
360
- });
361
-
362
- // Notify host when focusDate changes
363
- $effect(() => {
364
- const d = viewState.focusDate;
365
- ondatechange?.(d);
366
- });
367
-
368
- // Keep view state's week-start rule in sync with incoming prop changes.
369
- $effect(() => {
370
- if (viewState.mondayStart !== mondayStart) {
371
- viewState.setMondayStart(mondayStart);
372
- }
373
- });
374
-
375
- // Notify host when active view changes (e.g. via mode toggles).
376
- $effect(() => {
377
- onviewchange?.(viewState.view);
378
- });
379
-
380
- // ── Resolve active view ──
381
- // When mobile is active, Planner views get remapped to Mobile variants.
382
- // Agenda views stay as Agenda they're already list-based and will adapt
383
- // via the 'calendar:mobile' context flag.
384
- const resolvedView = $derived.by(() => {
385
- const requested = views.find((v) => v.id === viewState.view) ?? views[0];
386
- if (!useMobile || !requested) return requested;
387
- // Already a mobile view? Keep it.
388
- if (requested.id.endsWith('-mobile')) return requested;
389
- // Agenda views: keep as-is (they adapt internally via mobile context).
390
- if (requested.label === 'Agenda') return requested;
391
- // Planner / other views: remap to mobile variant with the same mode.
392
- const mobileVariant = views.find(
393
- (v) => v.id === `${requested.mode}-mobile`
394
- );
395
- return mobileVariant ?? requested;
396
- });
397
-
398
- // Backward-compat alias used in the template.
399
- const activeView = $derived(resolvedView);
400
-
401
- // Non-mobile views for mode pills (exclude mobile entries).
402
- const desktopViews = $derived(views.filter((v) => !v.id.endsWith('-mobile')));
403
-
404
- // ── Date label (always visible, centered over views) ──
405
- const dateLabel = $derived.by(() => {
406
- if (!showDates) {
407
- // Template week mode: just show weekday name (day) or nothing (week)
408
- if (viewState.mode === 'day') {
409
- return viewState.focusDate.toLocaleDateString(locale, { weekday: 'long' });
410
- }
411
- return ''; // week views have their own day headers
412
- }
413
- if (viewState.mode === 'day') {
414
- return viewState.focusDate.toLocaleDateString(locale, {
415
- weekday: 'long',
416
- month: 'short',
417
- day: 'numeric',
418
- });
419
- }
420
- return viewState.focusDate.toLocaleDateString(locale, {
421
- month: 'long',
422
- year: 'numeric',
423
- });
424
- });
425
-
426
- // Which modes are available?
427
- const modes = $derived.by(() => {
428
- const g = new Set(desktopViews.map((v) => v.mode));
429
- return (['day', 'week'] as const).filter((key) => g.has(key));
430
- });
431
-
432
- const L = $derived(getLabels());
433
-
434
- /** Switch to a different mode (day/week), preserving the current view label. */
435
- function switchMode(g: 'day' | 'week') {
436
- const currentLabel = desktopViews.find((v) => v.id === viewState.view)?.label
437
- ?? activeView?.label;
438
- const match = desktopViews.find((v) => v.mode === g && v.label === currentLabel);
439
- const fallback = desktopViews.find((v) => v.mode === g);
440
- const target = match ?? fallback;
441
- if (target) viewState.setView(target.id);
442
- }
443
-
444
- /** True when the current view range already includes today. */
445
- const viewIncludesToday = $derived.by(() => {
446
- const now = Date.now();
447
- const { start, end } = viewState.range;
448
- return now >= start.getTime() && now < end.getTime();
449
- });
450
- </script>
451
-
452
- <div
453
- class="cal"
454
- bind:this={calEl}
455
- style="{effectiveTheme}; --cal-h: {height}px; --cal-r: {borderRadius}px"
456
- role="region"
457
- aria-label={L.calendar}
458
- dir={dir}
459
- lang={locale}
460
- >
461
- <!-- ─── Mobile header (flow layout, no absolute) ─── -->
462
- {#if useMobile}
463
- <div class="cal-m-hd">
464
- <div class="cal-m-left">
465
- {#if showNavigation}
466
- <button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : L.previousWeek}>
467
- <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>
468
- </button>
469
- {/if}
470
-
471
- {#if showModePills && modes.length > 1}
472
- <div class="cal-m-pills" role="group" aria-label={L.viewMode}>
473
- {#each modes as g}
474
- <button
475
- class="cal-m-pill"
476
- class:cal-m-pill--active={viewState.mode === g}
477
- aria-pressed={viewState.mode === g}
478
- onclick={() => switchMode(g)}
479
- >
480
- {g === 'day' ? L.day : L.week}
481
- </button>
482
- {/each}
483
- </div>
484
- {/if}
485
- </div>
486
-
487
- <span class="cal-m-title">{dateLabel}</span>
488
-
489
- <div class="cal-m-right">
490
- {#if showNavigation}
491
- <button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : L.nextWeek}>
492
- <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>
493
- </button>
494
- {/if}
495
- </div>
496
- </div>
497
-
498
- <!-- Today pill — floats below header, doesn't affect header flow -->
499
- {#if showNavigation && !viewIncludesToday}
500
- <div class="cal-m-today-bar">
501
- <button class="cal-m-today" onclick={() => viewState.goToday()}>
502
- {L.today}
503
- </button>
504
- </div>
505
- {/if}
506
- {/if}
507
-
508
- <!-- Floating mode pills — desktop only (hidden for Agenda views) -->
509
- {#if !useMobile && showModePills && modes.length > 1 && activeView?.label !== 'Agenda'}
510
- <div class="cal-pills" role="group" aria-label={L.viewMode}>
511
- {#each modes as g}
512
- <button
513
- class="cal-pill"
514
- class:cal-pill--active={viewState.mode === g}
515
- aria-pressed={viewState.mode === g}
516
- onclick={() => switchMode(g)}
517
- >
518
- {g === 'day' ? L.day : L.week}
519
- </button>
520
- {/each}
521
- </div>
522
- {/if}
523
-
524
- <div class="cal-body">
525
- {#if activeView}
526
- {@const Comp = activeView.component}
527
- <Comp
528
- events={store.events}
529
- style={effectiveTheme}
530
- height={null}
531
- mode={activeView.mode}
532
- mondayStart={viewState.mondayStart}
533
- {locale}
534
- focusDate={viewState.focusDate}
535
- oneventclick={oneventclick}
536
- oneventcreate={effectiveCreate}
537
- readOnly={readOnly}
538
- visibleHours={visibleHours}
539
- selectedEventId={selection.selectedId}
540
- {...activeView.props ?? {}}
541
- />
542
- {:else}
543
- <div class="cal-empty">No views registered.</div>
544
- {/if}
545
- </div>
546
-
547
- {#if store.loading}
548
- <div class="cal-loading"></div>
549
- {/if}
550
- </div>
551
-
552
- <style>
553
- .cal {
554
- position: relative;
555
- height: var(--cal-h, 600px);
556
- background: var(--dt-bg, #0b0e14);
557
- border-radius: var(--cal-r, 12px);
558
- overflow: clip;
559
- display: flex;
560
- flex-direction: column;
561
- border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
562
- }
563
-
564
- /* ── Floating pills ── */
565
- .cal-pills {
566
- position: absolute;
567
- top: 22px;
568
- bottom: auto;
569
- left: 10px;
570
- z-index: 20;
571
- display: flex;
572
- gap: 2px;
573
- background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
574
- backdrop-filter: blur(6px);
575
- -webkit-backdrop-filter: blur(6px);
576
- border-radius: 8px;
577
- padding: 2px;
578
- border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
579
- }
580
-
581
- .cal-pill {
582
- border: none;
583
- background: transparent;
584
- color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
585
- cursor: pointer;
586
- font: 600 11px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
587
- padding: 6px 12px;
588
- border-radius: 6px;
589
- letter-spacing: 0.04em;
590
- text-transform: uppercase;
591
- transition: background 100ms, color 100ms;
592
- }
593
-
594
- .cal-pill:hover {
595
- color: var(--dt-text, rgba(226, 232, 240, 0.85));
596
- }
597
-
598
- .cal-pill--active {
599
- background: var(--dt-accent, #ef4444);
600
- color: var(--dt-btn-text, #fff);
601
- }
602
-
603
- .cal-pill:focus-visible {
604
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
605
- outline-offset: 2px;
606
- }
607
-
608
- .cal-body {
609
- flex: 1;
610
- min-height: 0;
611
- position: relative;
612
- overflow: hidden;
613
- }
614
-
615
- .cal-empty {
616
- display: flex;
617
- align-items: center;
618
- justify-content: center;
619
- height: 100%;
620
- font: 400 13px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
621
- color: var(--dt-text-3, rgba(100, 116, 139, 0.55));
622
- }
623
-
624
- .cal-loading {
625
- position: absolute;
626
- top: 0;
627
- left: 0;
628
- right: 0;
629
- height: 2px;
630
- background: linear-gradient(
631
- 90deg,
632
- transparent 0%,
633
- var(--dt-accent, #ef4444) 50%,
634
- transparent 100%
635
- );
636
- animation: cal-slide 1.2s ease-in-out infinite;
637
- }
638
-
639
- @keyframes cal-slide {
640
- 0% { transform: translateX(-100%); }
641
- 100% { transform: translateX(100%); }
642
- }
643
-
644
- /* ── Mobile header (flow layout) ── */
645
- .cal-m-hd {
646
- display: flex;
647
- align-items: center;
648
- gap: 4px;
649
- padding: 8px 8px 6px;
650
- border-bottom: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
651
- flex-shrink: 0;
652
- min-height: 44px;
653
- }
654
-
655
- .cal-m-left,
656
- .cal-m-right {
657
- display: flex;
658
- align-items: center;
659
- gap: 2px;
660
- flex-shrink: 0;
661
- }
662
-
663
- .cal-m-right {
664
- justify-content: flex-end;
665
- }
666
-
667
- .cal-m-nav {
668
- display: flex;
669
- align-items: center;
670
- justify-content: center;
671
- width: 32px;
672
- height: 32px;
673
- border: none;
674
- background: transparent;
675
- color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
676
- border-radius: 50%;
677
- cursor: pointer;
678
- transition: background 120ms, color 120ms;
679
- -webkit-tap-highlight-color: transparent;
680
- flex-shrink: 0;
681
- }
682
- .cal-m-nav:hover {
683
- color: var(--dt-text, rgba(226, 232, 240, 0.85));
684
- background: color-mix(in srgb, var(--dt-text, rgba(226, 232, 240, 0.85)) 8%, transparent);
685
- }
686
- .cal-m-nav:active {
687
- background: var(--dt-accent-dim, rgba(239, 68, 68, 0.12));
688
- }
689
- .cal-m-nav:focus-visible {
690
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
691
- outline-offset: 2px;
692
- }
693
-
694
- .cal-m-pills {
695
- display: flex;
696
- gap: 2px;
697
- background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
698
- border-radius: 8px;
699
- padding: 2px;
700
- border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
701
- flex-shrink: 0;
702
- }
703
- .cal-m-pill {
704
- border: none;
705
- background: transparent;
706
- color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
707
- cursor: pointer;
708
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
709
- padding: 5px 10px;
710
- border-radius: 6px;
711
- letter-spacing: 0.04em;
712
- text-transform: uppercase;
713
- transition: background 100ms, color 100ms;
714
- -webkit-tap-highlight-color: transparent;
715
- }
716
- .cal-m-pill:hover {
717
- color: var(--dt-text, rgba(226, 232, 240, 0.85));
718
- }
719
- .cal-m-pill--active {
720
- background: var(--dt-accent, #ef4444);
721
- color: var(--dt-btn-text, #fff);
722
- }
723
-
724
- .cal-m-title {
725
- flex: 1;
726
- text-align: center;
727
- font: 600 14px / 1.2 var(--dt-sans, system-ui, sans-serif);
728
- color: var(--dt-text, rgba(226, 232, 240, 0.85));
729
- white-space: nowrap;
730
- overflow: hidden;
731
- text-overflow: ellipsis;
732
- min-width: 0;
733
- }
734
-
735
- .cal-m-today-bar {
736
- display: flex;
737
- justify-content: center;
738
- padding: 12px 8px 6px;
739
- flex-shrink: 0;
740
- }
741
-
742
- .cal-m-today {
743
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
744
- color: var(--dt-accent, #ef4444);
745
- background: color-mix(in srgb, var(--dt-accent, #ef4444) 10%, transparent);
746
- border: none;
747
- padding: 5px 10px;
748
- border-radius: 6px;
749
- cursor: pointer;
750
- white-space: nowrap;
751
- letter-spacing: 0.04em;
752
- text-transform: uppercase;
753
- transition: background 120ms, color 120ms;
754
- -webkit-tap-highlight-color: transparent;
755
- flex-shrink: 0;
756
- }
757
- .cal-m-today:hover {
758
- background: color-mix(in srgb, var(--dt-accent, #ef4444) 18%, transparent);
759
- }
760
- .cal-m-today:active {
761
- background: color-mix(in srgb, var(--dt-accent, #ef4444) 25%, transparent);
762
- }
763
- .cal-m-today:focus-visible {
764
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
765
- outline-offset: 2px;
766
- }
767
- </style>
1
+ <!--
2
+ Calendar — the unified orchestrator.
3
+
4
+ Brings together: adapter → event store → view state + selection → active view.
5
+ Provides context so any descendant view can read the store/state via getContext().
6
+
7
+ Usage (minimal):
8
+ <Calendar adapter={myAdapter} theme={neutral} />
9
+
10
+ Usage (full control):
11
+ <Calendar
12
+ adapter={myAdapter}
13
+ view="week-planner"
14
+ theme={midnight}
15
+ height={600}
16
+ oneventclick={handleClick}
17
+ oneventcreate={handleCreate}
18
+ />
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
+ /**
112
+ * Mobile mode.
113
+ * - `'auto'` (default): detect via viewport width (< 768 px)
114
+ * - `true`: always use mobile views
115
+ * - `false`: never use mobile views
116
+ */
117
+ mobile?: 'auto' | boolean;
118
+
119
+ // ── Snippets ──
120
+ /** Custom event rendering snippet */
121
+ event?: Snippet<[TimelineEvent]>;
122
+ /** Content to show when no events are loaded */
123
+ empty?: Snippet;
124
+ /** Custom day header snippet. Receives { date, isToday, dayName }. */
125
+ dayHeader?: Snippet<[{ date: Date; isToday: boolean; dayName: string }]>;
126
+
127
+ // ── Callbacks ──
128
+ oneventclick?: (event: TimelineEvent) => void;
129
+ oneventcreate?: (range: { start: Date; end: Date }) => void;
130
+ oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
131
+ onviewchange?: (viewId: CalendarViewId) => void;
132
+ /** Called when the focused date changes (navigation, drag-scroll, etc.) */
133
+ ondatechange?: (date: Date) => void;
134
+ /** Called when the pointer enters an event (hover). */
135
+ oneventhover?: (event: TimelineEvent) => void;
136
+ }
137
+
138
+ // ── Built-in views (used when no custom views are provided) ──
139
+ const DEFAULT_VIEWS: CalendarView[] = [
140
+ { id: 'day-planner', label: 'Planner', mode: 'day', component: Planner },
141
+ { id: 'week-planner', label: 'Planner', mode: 'week', component: Planner },
142
+ { id: 'day-agenda', label: 'Agenda', mode: 'day', component: Agenda },
143
+ { id: 'week-agenda', label: 'Agenda', mode: 'week', component: Agenda },
144
+ { id: 'day-mobile', label: 'Mobile', mode: 'day', component: Mobile },
145
+ { id: 'week-mobile', label: 'Mobile', mode: 'week', component: Mobile },
146
+ ];
147
+
148
+ let {
149
+ adapter,
150
+ views = DEFAULT_VIEWS,
151
+ view: activeViewId,
152
+ theme = auto,
153
+ autoTheme,
154
+ mondayStart = true,
155
+ height: heightProp = 600,
156
+ borderRadius = 12,
157
+ dir,
158
+ locale,
159
+ readOnly = false,
160
+ visibleHours,
161
+ initialDate,
162
+ snapInterval = 15,
163
+ showModePills = true,
164
+ showNavigation = true,
165
+ equalDays = false,
166
+ showDates = true,
167
+ hideDays,
168
+ currentDate,
169
+ blockedSlots,
170
+ days,
171
+ minDuration,
172
+ maxDuration,
173
+ disabledDates,
174
+ mobile: mobileProp = 'auto',
175
+ event: eventSnippet,
176
+ empty: emptySnippet,
177
+ dayHeader: dayHeaderSnippet,
178
+ oneventclick,
179
+ oneventcreate,
180
+ oneventmove,
181
+ onviewchange,
182
+ ondatechange,
183
+ oneventhover,
184
+ }: Props = $props();
185
+
186
+ // In readOnly mode, suppress mutation callbacks
187
+ const effectiveCreate = $derived(readOnly ? undefined : oneventcreate);
188
+ const effectiveMove = $derived(readOnly ? undefined : oneventmove);
189
+
190
+ // ── Mobile detection ──
191
+ let isMobileViewport = $state(false);
192
+
193
+ onMount(() => {
194
+ if (typeof window === 'undefined') return;
195
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
196
+ isMobileViewport = mql.matches;
197
+ function onChange(e: MediaQueryListEvent) { isMobileViewport = e.matches; }
198
+ mql.addEventListener('change', onChange);
199
+ return () => mql.removeEventListener('change', onChange);
200
+ });
201
+
202
+ const useMobile = $derived(
203
+ mobileProp === 'auto' ? isMobileViewport : Boolean(mobileProp)
204
+ );
205
+
206
+ // ── Smart auto-theme ──
207
+ // When theme is empty (auto preset) and autoTheme is not false,
208
+ // probe the host page on mount and reactively watch for host theme changes.
209
+ let calEl: HTMLElement | undefined = $state();
210
+ let probedTheme = $state('');
211
+
212
+ onMount(() => {
213
+ if (!calEl) return;
214
+ // Only probe when using the auto preset (empty string)
215
+ const isAuto = theme === auto && autoTheme !== false;
216
+ if (!isAuto) return;
217
+
218
+ const opts: AutoThemeOptions = typeof autoTheme === 'object' ? autoTheme : {};
219
+ return observeHostTheme(calEl, (vars) => { probedTheme = vars; }, opts);
220
+ });
221
+
222
+ /** Effective theme: user-provided takes priority, otherwise probed auto. */
223
+ const effectiveTheme = $derived(theme === auto && autoTheme !== false ? probedTheme : theme);
224
+
225
+ // ── Create reactive state ──
226
+ const store: EventStore = $derived(createEventStore(adapter));
227
+ const viewState: ViewState = createViewState(untrack(() => ({
228
+ view: activeViewId ?? views[0]?.id,
229
+ mondayStart,
230
+ initialDate,
231
+ dayCount: days,
232
+ modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode,
233
+ })));
234
+ const selection: Selection = createSelection();
235
+ const drag: DragState = createDragState();
236
+
237
+ // ── Drag commit handler ──
238
+ // Views call this on pointer-up to process drag results.
239
+ function commitDrag(): void {
240
+ if (readOnly) { drag.cancel(); return; }
241
+ const mode = drag.mode;
242
+ const payload = drag.commit();
243
+ if (!payload) return;
244
+
245
+ let { start, end } = payload;
246
+
247
+ // Enforce min/max duration for create and resize
248
+ if (mode === 'create' || mode === 'resize-start' || mode === 'resize-end') {
249
+ const durationMs = end.getTime() - start.getTime();
250
+ const durationMin = durationMs / 60_000;
251
+ if (minDuration && durationMin < minDuration) {
252
+ if (mode === 'resize-start') {
253
+ start = new Date(end.getTime() - minDuration * 60_000);
254
+ } else {
255
+ end = new Date(start.getTime() + minDuration * 60_000);
256
+ }
257
+ }
258
+ if (maxDuration && durationMin > maxDuration) {
259
+ if (mode === 'resize-start') {
260
+ start = new Date(end.getTime() - maxDuration * 60_000);
261
+ } else {
262
+ end = new Date(start.getTime() + maxDuration * 60_000);
263
+ }
264
+ }
265
+ }
266
+
267
+ // Reject if target lands on a disabled date
268
+ if (disabledDates?.length) {
269
+ const startDay = new Date(start); startDay.setHours(0, 0, 0, 0);
270
+ const endDay = new Date(end.getTime() - 1); endDay.setHours(0, 0, 0, 0);
271
+ for (const dd of disabledDates) {
272
+ const dt = new Date(dd); dt.setHours(0, 0, 0, 0);
273
+ const ts = dt.getTime();
274
+ if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
275
+ }
276
+ }
277
+
278
+ // Reject if target overlaps a blocked slot
279
+ if (blockedSlots?.length) {
280
+ const startH = start.getHours() + start.getMinutes() / 60;
281
+ const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
282
+ const jsDay = start.getDay();
283
+ const isoDay = jsDay === 0 ? 7 : jsDay;
284
+ for (const slot of blockedSlots) {
285
+ if (slot.day && slot.day !== isoDay) continue;
286
+ if (startH < slot.end && endH > slot.start) return;
287
+ }
288
+ }
289
+
290
+ if ((mode === 'move' || mode === 'resize-start' || mode === 'resize-end') && payload.eventId) {
291
+ store.move(payload.eventId, start, end);
292
+ const ev = store.byId(payload.eventId);
293
+ if (ev) effectiveMove?.(ev, start, end);
294
+ } else if (mode === 'create') {
295
+ effectiveCreate?.({ start, end });
296
+ }
297
+ }
298
+
299
+ // Context is set once at mount; engine objects hold $state internally
300
+ // so consumers reading from them stay reactive automatically.
301
+ // Wrap store in a getter since it's $derived and may change when adapter changes.
302
+ setContext('calendar:store', { get current() { return store; } });
303
+ setContext('calendar:viewState', viewState);
304
+ setContext('calendar:selection', selection);
305
+ setContext('calendar:drag', drag);
306
+ setContext('calendar:commitDrag', commitDrag);
307
+ // Callbacks: provide a getter-based object so context consumers
308
+ // always see the latest callback references.
309
+ setContext('calendar:callbacks', {
310
+ get oneventclick() { return oneventclick; },
311
+ get oneventcreate() { return effectiveCreate; },
312
+ get oneventmove() { return effectiveMove; },
313
+ get oneventhover() { return oneventhover; },
314
+ });
315
+ setContext('calendar:readOnly', { get current() { return readOnly; } });
316
+ setContext('calendar:visibleHours', { get current() { return visibleHours; } });
317
+ setContext('calendar:snapInterval', { get current() { return snapInterval; } });
318
+ setContext('calendar:eventSnippet', { get current() { return eventSnippet; } });
319
+ setContext('calendar:emptySnippet', { get current() { return emptySnippet; } });
320
+ setContext('calendar:showNavigation', { get current() { return showNavigation; } });
321
+ setContext('calendar:equalDays', { get current() { return equalDays; } });
322
+ setContext('calendar:showDates', { get current() { return showDates; } });
323
+ setContext('calendar:hideDays', { get current() { return hideDays; } });
324
+ setContext('calendar:blockedSlots', { get current() { return blockedSlots; } });
325
+ setContext('calendar:dayHeaderSnippet', { get current() { return dayHeaderSnippet; } });
326
+ setContext('calendar:minDuration', { get current() { return minDuration; } });
327
+ setContext('calendar:maxDuration', { get current() { return maxDuration; } });
328
+ setContext('calendar:disabledDates', { get current() { return disabledDates; } });
329
+ setContext('calendar:mobile', { get current() { return useMobile; } });
330
+ setContext('calendar:autoHeight', { get current() { return heightProp === 'auto'; } });
331
+
332
+ // ── Load range signal ──
333
+ // Views can write a wider range here to override the default viewState.range.
334
+ // This lets infinite-scroll views (PlannerWeek, PlannerDay) declare their
335
+ // buffer needs without directly calling store.load().
336
+ let viewLoadRange = $state<{ start: Date; end: Date } | null>(null);
337
+ setContext('calendar:loadRange', {
338
+ get current() { return viewLoadRange; },
339
+ set(range: { start: Date; end: Date } | null) { viewLoadRange = range; },
340
+ });
341
+
342
+ // ── Load events when effective range changes ──
343
+ $effect(() => {
344
+ const range = viewLoadRange ?? viewState.range;
345
+ store.load({ start: range.start, end: range.end });
346
+ });
347
+
348
+ // Keep active view in sync when external view prop changes after mount.
349
+ $effect(() => {
350
+ if (activeViewId) viewState.setView(activeViewId);
351
+ });
352
+
353
+ // Sync controlled currentDate prop → viewState
354
+ $effect(() => {
355
+ if (currentDate) viewState.setFocusDate(currentDate);
356
+ });
357
+
358
+ // Sync days prop → viewState.dayCount
359
+ $effect(() => {
360
+ if (days !== undefined && viewState.dayCount !== days) viewState.setDayCount(days);
361
+ });
362
+
363
+ // Notify host when focusDate changes
364
+ $effect(() => {
365
+ const d = viewState.focusDate;
366
+ ondatechange?.(d);
367
+ });
368
+
369
+ // Keep view state's week-start rule in sync with incoming prop changes.
370
+ $effect(() => {
371
+ if (viewState.mondayStart !== mondayStart) {
372
+ viewState.setMondayStart(mondayStart);
373
+ }
374
+ });
375
+
376
+ // Notify host when active view changes (e.g. via mode toggles).
377
+ $effect(() => {
378
+ onviewchange?.(viewState.view);
379
+ });
380
+
381
+ // ── Resolve active view ──
382
+ // When mobile is active, Planner views get remapped to Mobile variants.
383
+ // Agenda views stay as Agenda — they're already list-based and will adapt
384
+ // via the 'calendar:mobile' context flag.
385
+ const resolvedView = $derived.by(() => {
386
+ const requested = views.find((v) => v.id === viewState.view) ?? views[0];
387
+ if (!useMobile || !requested) return requested;
388
+ // Already a mobile view? Keep it.
389
+ if (requested.id.endsWith('-mobile')) return requested;
390
+ // Agenda views: keep as-is (they adapt internally via mobile context).
391
+ if (requested.label === 'Agenda') return requested;
392
+ // Planner / other views: remap to mobile variant with the same mode.
393
+ const mobileVariant = views.find(
394
+ (v) => v.id === `${requested.mode}-mobile`
395
+ );
396
+ return mobileVariant ?? requested;
397
+ });
398
+
399
+ // Backward-compat alias used in the template.
400
+ const activeView = $derived(resolvedView);
401
+
402
+ // Non-mobile views for mode pills (exclude mobile entries).
403
+ const desktopViews = $derived(views.filter((v) => !v.id.endsWith('-mobile')));
404
+
405
+ // ── Date label (always visible, centered over views) ──
406
+ const dateLabel = $derived.by(() => {
407
+ if (!showDates) {
408
+ // Template week mode: just show weekday name (day) or nothing (week)
409
+ if (viewState.mode === 'day') {
410
+ return viewState.focusDate.toLocaleDateString(locale, { weekday: 'long' });
411
+ }
412
+ return ''; // week views have their own day headers
413
+ }
414
+ if (viewState.mode === 'day') {
415
+ return viewState.focusDate.toLocaleDateString(locale, {
416
+ weekday: 'long',
417
+ month: 'short',
418
+ day: 'numeric',
419
+ });
420
+ }
421
+ return viewState.focusDate.toLocaleDateString(locale, {
422
+ month: 'long',
423
+ year: 'numeric',
424
+ });
425
+ });
426
+
427
+ // Which modes are available?
428
+ const modes = $derived.by(() => {
429
+ const g = new Set(desktopViews.map((v) => v.mode));
430
+ return (['day', 'week'] as const).filter((key) => g.has(key));
431
+ });
432
+
433
+ const L = $derived(getLabels());
434
+
435
+ /** Switch to a different mode (day/week), preserving the current view label. */
436
+ function switchMode(g: 'day' | 'week') {
437
+ const currentLabel = desktopViews.find((v) => v.id === viewState.view)?.label
438
+ ?? activeView?.label;
439
+ const match = desktopViews.find((v) => v.mode === g && v.label === currentLabel);
440
+ const fallback = desktopViews.find((v) => v.mode === g);
441
+ const target = match ?? fallback;
442
+ if (target) viewState.setView(target.id);
443
+ }
444
+
445
+ /** True when the current view range already includes today. */
446
+ const viewIncludesToday = $derived.by(() => {
447
+ const now = Date.now();
448
+ const { start, end } = viewState.range;
449
+ return now >= start.getTime() && now < end.getTime();
450
+ });
451
+ </script>
452
+
453
+ <div
454
+ class="cal"
455
+ bind:this={calEl}
456
+ style="{effectiveTheme}; {heightProp === 'auto' ? '' : `--cal-h: ${heightProp}px;`} --cal-r: {borderRadius}px"
457
+ class:cal--auto={heightProp === 'auto'}
458
+ role="region"
459
+ aria-label={L.calendar}
460
+ dir={dir}
461
+ lang={locale}
462
+ >
463
+ <!-- ─── Mobile header (flow layout, no absolute) ─── -->
464
+ {#if useMobile}
465
+ <div class="cal-m-hd">
466
+ <div class="cal-m-left">
467
+ {#if showNavigation}
468
+ <button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : L.previousWeek}>
469
+ <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>
470
+ </button>
471
+ {/if}
472
+
473
+ {#if showModePills && modes.length > 1}
474
+ <div class="cal-m-pills" role="group" aria-label={L.viewMode}>
475
+ {#each modes as g}
476
+ <button
477
+ class="cal-m-pill"
478
+ class:cal-m-pill--active={viewState.mode === g}
479
+ aria-pressed={viewState.mode === g}
480
+ onclick={() => switchMode(g)}
481
+ >
482
+ {g === 'day' ? L.day : L.week}
483
+ </button>
484
+ {/each}
485
+ </div>
486
+ {/if}
487
+ </div>
488
+
489
+ <span class="cal-m-title">{dateLabel}</span>
490
+
491
+ <div class="cal-m-right">
492
+ {#if showNavigation}
493
+ <button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : L.nextWeek}>
494
+ <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>
495
+ </button>
496
+ {/if}
497
+ </div>
498
+ </div>
499
+
500
+ <!-- Today pill — floats below header, doesn't affect header flow -->
501
+ {#if showNavigation && !viewIncludesToday}
502
+ <div class="cal-m-today-bar">
503
+ <button class="cal-m-today" onclick={() => viewState.goToday()}>
504
+ {L.today}
505
+ </button>
506
+ </div>
507
+ {/if}
508
+ {/if}
509
+
510
+ <!-- Floating mode pills desktop only (hidden for Agenda views) -->
511
+ {#if !useMobile && showModePills && modes.length > 1 && activeView?.label !== 'Agenda'}
512
+ <div class="cal-pills" role="group" aria-label={L.viewMode}>
513
+ {#each modes as g}
514
+ <button
515
+ class="cal-pill"
516
+ class:cal-pill--active={viewState.mode === g}
517
+ aria-pressed={viewState.mode === g}
518
+ onclick={() => switchMode(g)}
519
+ >
520
+ {g === 'day' ? L.day : L.week}
521
+ </button>
522
+ {/each}
523
+ </div>
524
+ {/if}
525
+
526
+ <div class="cal-body">
527
+ {#if activeView}
528
+ {@const Comp = activeView.component}
529
+ <Comp
530
+ events={store.events}
531
+ style={effectiveTheme}
532
+ height={null}
533
+ mode={activeView.mode}
534
+ mondayStart={viewState.mondayStart}
535
+ {locale}
536
+ focusDate={viewState.focusDate}
537
+ oneventclick={oneventclick}
538
+ oneventcreate={effectiveCreate}
539
+ readOnly={readOnly}
540
+ visibleHours={visibleHours}
541
+ selectedEventId={selection.selectedId}
542
+ {...activeView.props ?? {}}
543
+ />
544
+ {:else}
545
+ <div class="cal-empty">No views registered.</div>
546
+ {/if}
547
+ </div>
548
+
549
+ {#if store.loading}
550
+ <div class="cal-loading"></div>
551
+ {/if}
552
+ </div>
553
+
554
+ <style>
555
+ .cal {
556
+ position: relative;
557
+ height: var(--cal-h, 600px);
558
+ background: var(--dt-bg, #0b0e14);
559
+ border-radius: var(--cal-r, 12px);
560
+ overflow: clip;
561
+ display: flex;
562
+ flex-direction: column;
563
+ border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
564
+ }
565
+ .cal--auto {
566
+ height: auto;
567
+ overflow: visible;
568
+ }
569
+
570
+ /* ── Floating pills ── */
571
+ .cal-pills {
572
+ position: absolute;
573
+ top: 22px;
574
+ bottom: auto;
575
+ left: 10px;
576
+ z-index: 20;
577
+ display: flex;
578
+ gap: 2px;
579
+ background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
580
+ backdrop-filter: blur(6px);
581
+ -webkit-backdrop-filter: blur(6px);
582
+ border-radius: 8px;
583
+ padding: 2px;
584
+ border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
585
+ }
586
+
587
+ .cal-pill {
588
+ border: none;
589
+ background: transparent;
590
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
591
+ cursor: pointer;
592
+ font: 600 11px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
593
+ padding: 6px 12px;
594
+ border-radius: 6px;
595
+ letter-spacing: 0.04em;
596
+ text-transform: uppercase;
597
+ transition: background 100ms, color 100ms;
598
+ }
599
+
600
+ .cal-pill:hover {
601
+ color: var(--dt-text, rgba(226, 232, 240, 0.85));
602
+ }
603
+
604
+ .cal-pill--active {
605
+ background: var(--dt-accent, #ef4444);
606
+ color: var(--dt-btn-text, #fff);
607
+ }
608
+
609
+ .cal-pill:focus-visible {
610
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
611
+ outline-offset: 2px;
612
+ }
613
+
614
+ .cal-body {
615
+ flex: 1;
616
+ min-height: 0;
617
+ position: relative;
618
+ overflow: hidden;
619
+ }
620
+ .cal--auto .cal-body {
621
+ overflow: visible;
622
+ }
623
+
624
+ .cal-empty {
625
+ display: flex;
626
+ align-items: center;
627
+ justify-content: center;
628
+ height: 100%;
629
+ font: 400 13px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
630
+ color: var(--dt-text-3, rgba(100, 116, 139, 0.55));
631
+ }
632
+
633
+ .cal-loading {
634
+ position: absolute;
635
+ top: 0;
636
+ left: 0;
637
+ right: 0;
638
+ height: 2px;
639
+ background: linear-gradient(
640
+ 90deg,
641
+ transparent 0%,
642
+ var(--dt-accent, #ef4444) 50%,
643
+ transparent 100%
644
+ );
645
+ animation: cal-slide 1.2s ease-in-out infinite;
646
+ }
647
+
648
+ @keyframes cal-slide {
649
+ 0% { transform: translateX(-100%); }
650
+ 100% { transform: translateX(100%); }
651
+ }
652
+
653
+ /* ── Mobile header (flow layout) ── */
654
+ .cal-m-hd {
655
+ display: flex;
656
+ align-items: center;
657
+ gap: 4px;
658
+ padding: 8px 8px 6px;
659
+ border-bottom: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
660
+ flex-shrink: 0;
661
+ min-height: 44px;
662
+ }
663
+
664
+ .cal-m-left,
665
+ .cal-m-right {
666
+ display: flex;
667
+ align-items: center;
668
+ gap: 2px;
669
+ flex-shrink: 0;
670
+ }
671
+
672
+ .cal-m-right {
673
+ justify-content: flex-end;
674
+ }
675
+
676
+ .cal-m-nav {
677
+ display: flex;
678
+ align-items: center;
679
+ justify-content: center;
680
+ width: 32px;
681
+ height: 32px;
682
+ border: none;
683
+ background: transparent;
684
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
685
+ border-radius: 50%;
686
+ cursor: pointer;
687
+ transition: background 120ms, color 120ms;
688
+ -webkit-tap-highlight-color: transparent;
689
+ flex-shrink: 0;
690
+ }
691
+ .cal-m-nav:hover {
692
+ color: var(--dt-text, rgba(226, 232, 240, 0.85));
693
+ background: color-mix(in srgb, var(--dt-text, rgba(226, 232, 240, 0.85)) 8%, transparent);
694
+ }
695
+ .cal-m-nav:active {
696
+ background: var(--dt-accent-dim, rgba(239, 68, 68, 0.12));
697
+ }
698
+ .cal-m-nav:focus-visible {
699
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
700
+ outline-offset: 2px;
701
+ }
702
+
703
+ .cal-m-pills {
704
+ display: flex;
705
+ gap: 2px;
706
+ background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
707
+ border-radius: 8px;
708
+ padding: 2px;
709
+ border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
710
+ flex-shrink: 0;
711
+ }
712
+ .cal-m-pill {
713
+ border: none;
714
+ background: transparent;
715
+ color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
716
+ cursor: pointer;
717
+ font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
718
+ padding: 5px 10px;
719
+ border-radius: 6px;
720
+ letter-spacing: 0.04em;
721
+ text-transform: uppercase;
722
+ transition: background 100ms, color 100ms;
723
+ -webkit-tap-highlight-color: transparent;
724
+ }
725
+ .cal-m-pill:hover {
726
+ color: var(--dt-text, rgba(226, 232, 240, 0.85));
727
+ }
728
+ .cal-m-pill--active {
729
+ background: var(--dt-accent, #ef4444);
730
+ color: var(--dt-btn-text, #fff);
731
+ }
732
+
733
+ .cal-m-title {
734
+ flex: 1;
735
+ text-align: center;
736
+ font: 600 14px / 1.2 var(--dt-sans, system-ui, sans-serif);
737
+ color: var(--dt-text, rgba(226, 232, 240, 0.85));
738
+ white-space: nowrap;
739
+ overflow: hidden;
740
+ text-overflow: ellipsis;
741
+ min-width: 0;
742
+ }
743
+
744
+ .cal-m-today-bar {
745
+ display: flex;
746
+ justify-content: center;
747
+ padding: 12px 8px 6px;
748
+ flex-shrink: 0;
749
+ }
750
+
751
+ .cal-m-today {
752
+ font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
753
+ color: var(--dt-accent, #ef4444);
754
+ background: color-mix(in srgb, var(--dt-accent, #ef4444) 10%, transparent);
755
+ border: none;
756
+ padding: 5px 10px;
757
+ border-radius: 6px;
758
+ cursor: pointer;
759
+ white-space: nowrap;
760
+ letter-spacing: 0.04em;
761
+ text-transform: uppercase;
762
+ transition: background 120ms, color 120ms;
763
+ -webkit-tap-highlight-color: transparent;
764
+ flex-shrink: 0;
765
+ }
766
+ .cal-m-today:hover {
767
+ background: color-mix(in srgb, var(--dt-accent, #ef4444) 18%, transparent);
768
+ }
769
+ .cal-m-today:active {
770
+ background: color-mix(in srgb, var(--dt-accent, #ef4444) 25%, transparent);
771
+ }
772
+ .cal-m-today:focus-visible {
773
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
774
+ outline-offset: 2px;
775
+ }
776
+ </style>