@nomideusz/svelte-calendar 0.5.2 → 0.6.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 (44) hide show
  1. package/README.md +408 -178
  2. package/dist/adapters/memory.d.ts +7 -9
  3. package/dist/adapters/memory.js +8 -23
  4. package/dist/adapters/recurring.d.ts +3 -8
  5. package/dist/adapters/recurring.js +27 -30
  6. package/dist/calendar/Calendar.svelte +457 -46
  7. package/dist/calendar/Calendar.svelte.d.ts +55 -4
  8. package/dist/core/index.d.ts +2 -2
  9. package/dist/core/index.js +1 -1
  10. package/dist/core/locale.js +2 -2
  11. package/dist/core/palette.d.ts +5 -0
  12. package/dist/core/palette.js +8 -0
  13. package/dist/core/types.d.ts +14 -0
  14. package/dist/engine/event-store.svelte.d.ts +0 -17
  15. package/dist/engine/event-store.svelte.js +30 -17
  16. package/dist/engine/index.d.ts +1 -1
  17. package/dist/engine/view-state.svelte.d.ts +10 -5
  18. package/dist/engine/view-state.svelte.js +32 -16
  19. package/dist/index.d.ts +7 -7
  20. package/dist/index.js +3 -4
  21. package/dist/theme/auto.d.ts +53 -0
  22. package/dist/theme/auto.js +534 -0
  23. package/dist/theme/index.d.ts +3 -1
  24. package/dist/theme/index.js +3 -1
  25. package/dist/theme/presets.d.ts +20 -6
  26. package/dist/theme/presets.js +19 -6
  27. package/dist/views/agenda/AgendaDay.svelte +116 -37
  28. package/dist/views/agenda/AgendaWeek.svelte +219 -70
  29. package/dist/views/agenda/index.d.ts +0 -2
  30. package/dist/views/agenda/index.js +0 -2
  31. package/dist/views/index.d.ts +3 -2
  32. package/dist/views/index.js +3 -2
  33. package/dist/views/mobile/Mobile.svelte +17 -0
  34. package/dist/views/mobile/Mobile.svelte.d.ts +7 -0
  35. package/dist/views/mobile/MobileDay.svelte +665 -0
  36. package/dist/views/mobile/MobileDay.svelte.d.ts +20 -0
  37. package/dist/views/mobile/MobileWeek.svelte +456 -0
  38. package/dist/views/mobile/MobileWeek.svelte.d.ts +20 -0
  39. package/dist/views/mobile/index.d.ts +1 -0
  40. package/dist/views/mobile/index.js +1 -0
  41. package/dist/views/planner/PlannerDay.svelte +144 -10
  42. package/dist/views/planner/PlannerWeek.svelte +145 -40
  43. package/dist/widget/CalendarWidget.svelte +2 -17
  44. package/package.json +2 -1
@@ -4,16 +4,14 @@
4
4
  Brings together: adapter → event store → view state + selection → active view.
5
5
  Provides context so any descendant view can read the store/state via getContext().
6
6
 
7
- Usage:
7
+ Usage (minimal):
8
+ <Calendar adapter={myAdapter} theme={neutral} />
9
+
10
+ Usage (full control):
8
11
  <Calendar
9
12
  adapter={myAdapter}
10
- views={[
11
- { id: 'week-planner', label: 'Planner', component: Planner, props: { mode: 'week' } },
12
- { id: 'week-agenda', label: 'Agenda', component: Agenda },
13
- { id: 'day-planner', label: 'Planner', component: Planner, props: { mode: 'day' } },
14
- ]}
15
- defaultView="week-planner"
16
- theme={presets.midnight}
13
+ view="week-planner"
14
+ theme={midnight}
17
15
  height={600}
18
16
  oneventclick={handleClick}
19
17
  oneventcreate={handleCreate}
@@ -27,15 +25,25 @@
27
25
  import { createViewState, type ViewState } from '../engine/view-state.svelte.js';
28
26
  import { createSelection, type Selection } from '../engine/selection.svelte.js';
29
27
  import { createDragState, type DragState } from '../engine/drag.svelte.js';
30
- import type { TimelineEvent } from '../core/types.js';
28
+ import { onMount } from 'svelte';
29
+ import type { TimelineEvent, BlockedSlot } from '../core/types.js';
31
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;
32
40
 
33
41
  /** One view registration */
34
42
  export interface CalendarView {
35
43
  id: CalendarViewId;
36
44
  label: string;
37
45
  /** day or week */
38
- granularity: 'day' | 'week';
46
+ mode: 'day' | 'week';
39
47
  /** The Svelte component to render */
40
48
  component: Component<Record<string, unknown>>;
41
49
  /** Extra props to pass through (e.g. hourHeight, specialized settings) */
@@ -47,14 +55,25 @@
47
55
  adapter: CalendarAdapter;
48
56
  /** Registered views */
49
57
  views?: CalendarView[];
50
- /** Initial view */
51
- defaultView?: CalendarViewId;
58
+ /** Active view ID (defaults to first registered view) */
59
+ view?: CalendarViewId;
52
60
  /** CSS theme string (--dt-* inline style) */
53
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;
54
71
  /** Start week on Monday */
55
72
  mondayStart?: boolean;
56
73
  /** Total height */
57
74
  height?: number;
75
+ /** Border radius in px (default: 12). Set to 0 for no rounding. */
76
+ borderRadius?: number;
58
77
  /** Text direction: 'ltr' (default), 'rtl', or 'auto' */
59
78
  dir?: 'ltr' | 'rtl' | 'auto';
60
79
  /** BCP 47 locale tag (e.g. 'en-US', 'ar-SA') — sets lang and locale for formatting */
@@ -67,52 +86,150 @@
67
86
  initialDate?: Date;
68
87
  /** Drag snap interval in minutes (default: 15) */
69
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;
70
118
 
71
119
  // ── Snippets ──
72
120
  /** Custom event rendering snippet */
73
121
  event?: Snippet<[TimelineEvent]>;
74
122
  /** Content to show when no events are loaded */
75
123
  empty?: Snippet;
124
+ /** Custom day header snippet. Receives { date, isToday, dayName }. */
125
+ dayHeader?: Snippet<[{ date: Date; isToday: boolean; dayName: string }]>;
76
126
 
77
127
  // ── Callbacks ──
78
128
  oneventclick?: (event: TimelineEvent) => void;
79
129
  oneventcreate?: (range: { start: Date; end: Date }) => void;
80
130
  oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
81
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;
82
136
  }
83
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
+
84
148
  let {
85
149
  adapter,
86
- views = [],
87
- defaultView = 'week-planner',
88
- theme = '',
150
+ views = DEFAULT_VIEWS,
151
+ view: activeViewId,
152
+ theme = auto,
153
+ autoTheme,
89
154
  mondayStart = true,
90
155
  height = 600,
156
+ borderRadius = 12,
91
157
  dir,
92
158
  locale,
93
159
  readOnly = false,
94
160
  visibleHours,
95
161
  initialDate,
96
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',
97
175
  event: eventSnippet,
98
176
  empty: emptySnippet,
177
+ dayHeader: dayHeaderSnippet,
99
178
  oneventclick,
100
179
  oneventcreate,
101
180
  oneventmove,
102
181
  onviewchange,
182
+ ondatechange,
183
+ oneventhover,
103
184
  }: Props = $props();
104
185
 
105
186
  // In readOnly mode, suppress mutation callbacks
106
187
  const effectiveCreate = $derived(readOnly ? undefined : oneventcreate);
107
188
  const effectiveMove = $derived(readOnly ? undefined : oneventmove);
108
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
+
109
225
  // ── Create reactive state ──
110
226
  const store: EventStore = $derived(createEventStore(adapter));
111
227
  const viewState: ViewState = createViewState(untrack(() => ({
112
- defaultView,
228
+ view: activeViewId ?? views[0]?.id,
113
229
  mondayStart,
114
230
  initialDate,
115
- granularityForView: (viewId) => views.find((v) => v.id === viewId)?.granularity,
231
+ dayCount: days,
232
+ modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode,
116
233
  })));
117
234
  const selection: Selection = createSelection();
118
235
  const drag: DragState = createDragState();
@@ -125,12 +242,57 @@
125
242
  const payload = drag.commit();
126
243
  if (!payload) return;
127
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
+
128
290
  if ((mode === 'move' || mode === 'resize-start' || mode === 'resize-end') && payload.eventId) {
129
- store.move(payload.eventId, payload.start, payload.end);
291
+ store.move(payload.eventId, start, end);
130
292
  const ev = store.byId(payload.eventId);
131
- if (ev) effectiveMove?.(ev, payload.start, payload.end);
293
+ if (ev) effectiveMove?.(ev, start, end);
132
294
  } else if (mode === 'create') {
133
- effectiveCreate?.({ start: payload.start, end: payload.end });
295
+ effectiveCreate?.({ start, end });
134
296
  }
135
297
  }
136
298
 
@@ -148,22 +310,59 @@
148
310
  get oneventclick() { return oneventclick; },
149
311
  get oneventcreate() { return effectiveCreate; },
150
312
  get oneventmove() { return effectiveMove; },
313
+ get oneventhover() { return oneventhover; },
151
314
  });
152
315
  setContext('calendar:readOnly', { get current() { return readOnly; } });
153
316
  setContext('calendar:visibleHours', { get current() { return visibleHours; } });
154
317
  setContext('calendar:snapInterval', { get current() { return snapInterval; } });
155
318
  setContext('calendar:eventSnippet', { get current() { return eventSnippet; } });
156
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
+ });
157
340
 
158
- // ── Load events when range changes ──
341
+ // ── Load events when effective range changes ──
159
342
  $effect(() => {
160
- const { start, end } = viewState.range;
161
- store.load({ start, end });
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);
162
360
  });
163
361
 
164
- // Keep active view in sync when external defaultView changes after mount.
362
+ // Notify host when focusDate changes
165
363
  $effect(() => {
166
- viewState.setView(defaultView);
364
+ const d = viewState.focusDate;
365
+ ondatechange?.(d);
167
366
  });
168
367
 
169
368
  // Keep view state's week-start rule in sync with incoming prop changes.
@@ -173,17 +372,45 @@
173
372
  }
174
373
  });
175
374
 
176
- // Notify host when active view changes (e.g. via granularity toggles).
375
+ // Notify host when active view changes (e.g. via mode toggles).
177
376
  $effect(() => {
178
377
  onviewchange?.(viewState.view);
179
378
  });
180
379
 
181
380
  // ── Resolve active view ──
182
- const activeView = $derived(views.find((v) => v.id === viewState.view) ?? views[0]);
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')));
183
403
 
184
404
  // ── Date label (always visible, centered over views) ──
185
405
  const dateLabel = $derived.by(() => {
186
- if (viewState.granularity === 'day') {
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') {
187
414
  return viewState.focusDate.toLocaleDateString(locale, {
188
415
  weekday: 'long',
189
416
  month: 'short',
@@ -196,38 +423,97 @@
196
423
  });
197
424
  });
198
425
 
199
- // Which granularities are available?
200
- const granularities = $derived.by(() => {
201
- const g = new Set(views.map((v) => v.granularity));
426
+ // Which modes are available?
427
+ const modes = $derived.by(() => {
428
+ const g = new Set(desktopViews.map((v) => v.mode));
202
429
  return (['day', 'week'] as const).filter((key) => g.has(key));
203
430
  });
204
431
 
205
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
+ });
206
450
  </script>
207
451
 
208
452
  <div
209
453
  class="cal"
210
- style="{theme}; --cal-h: {height}px"
454
+ bind:this={calEl}
455
+ style="{effectiveTheme}; --cal-h: {height}px; --cal-r: {borderRadius}px"
211
456
  role="region"
212
457
  aria-label={L.calendar}
213
458
  dir={dir}
214
459
  lang={locale}
215
460
  >
216
- <!-- Floating granularity pills (hidden for Agenda views) -->
217
- {#if granularities.length > 1 && activeView?.label !== 'Agenda'}
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'}
218
510
  <div class="cal-pills" role="group" aria-label={L.viewMode}>
219
- {#each granularities as g}
511
+ {#each modes as g}
220
512
  <button
221
513
  class="cal-pill"
222
- class:cal-pill--active={viewState.granularity === g}
223
- aria-pressed={viewState.granularity === g}
224
- onclick={() => {
225
- const currentLabel = views.find((v) => v.id === viewState.view)?.label;
226
- const match = views.find((v) => v.granularity === g && v.label === currentLabel);
227
- const fallback = views.find((v) => v.granularity === g);
228
- const target = match ?? fallback;
229
- if (target) viewState.setView(target.id);
230
- }}
514
+ class:cal-pill--active={viewState.mode === g}
515
+ aria-pressed={viewState.mode === g}
516
+ onclick={() => switchMode(g)}
231
517
  >
232
518
  {g === 'day' ? L.day : L.week}
233
519
  </button>
@@ -240,8 +526,9 @@
240
526
  {@const Comp = activeView.component}
241
527
  <Comp
242
528
  events={store.events}
243
- style={theme}
529
+ style={effectiveTheme}
244
530
  height={null}
531
+ mode={activeView.mode}
245
532
  mondayStart={viewState.mondayStart}
246
533
  {locale}
247
534
  focusDate={viewState.focusDate}
@@ -267,7 +554,7 @@
267
554
  position: relative;
268
555
  height: var(--cal-h, 600px);
269
556
  background: var(--dt-bg, #0b0e14);
270
- border-radius: 12px;
557
+ border-radius: var(--cal-r, 12px);
271
558
  overflow: clip;
272
559
  display: flex;
273
560
  flex-direction: column;
@@ -353,4 +640,128 @@
353
640
  0% { transform: translateX(-100%); }
354
641
  100% { transform: translateX(100%); }
355
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
+ }
356
767
  </style>