@nomideusz/svelte-calendar 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +192 -10
  2. package/dist/adapters/composite.d.ts +15 -0
  3. package/dist/adapters/composite.js +65 -0
  4. package/dist/adapters/index.d.ts +5 -1
  5. package/dist/adapters/index.js +2 -0
  6. package/dist/adapters/mapped.d.ts +170 -0
  7. package/dist/adapters/mapped.js +343 -0
  8. package/dist/adapters/recurring.d.ts +4 -0
  9. package/dist/adapters/recurring.js +5 -10
  10. package/dist/adapters/types.d.ts +11 -2
  11. package/dist/assets/favicon.svg +9 -1
  12. package/dist/calendar/Calendar.svelte +161 -90
  13. package/dist/calendar/Calendar.svelte.d.ts +12 -0
  14. package/dist/core/index.d.ts +3 -1
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/measure.d.ts +104 -0
  17. package/dist/core/measure.js +195 -0
  18. package/dist/core/time.js +2 -2
  19. package/dist/core/types.d.ts +32 -0
  20. package/dist/engine/event-store.svelte.js +22 -7
  21. package/dist/headless/create-agenda.svelte.d.ts +67 -0
  22. package/dist/headless/create-agenda.svelte.js +139 -0
  23. package/dist/headless/create-calendar.svelte.d.ts +2 -0
  24. package/dist/headless/create-calendar.svelte.js +380 -0
  25. package/dist/headless/index.d.ts +4 -0
  26. package/dist/headless/index.js +2 -0
  27. package/dist/headless/types.d.ts +219 -0
  28. package/dist/headless/types.js +1 -0
  29. package/dist/index.d.ts +6 -4
  30. package/dist/index.js +4 -2
  31. package/dist/primitives/DayHeader.svelte +6 -6
  32. package/dist/primitives/EmptySlot.svelte +3 -3
  33. package/dist/primitives/EventBlock.svelte +99 -14
  34. package/dist/primitives/NowIndicator.svelte +5 -5
  35. package/dist/views/agenda/AgendaDay.svelte +215 -116
  36. package/dist/views/agenda/AgendaWeek.svelte +166 -113
  37. package/dist/views/mobile/MobileDay.svelte +45 -21
  38. package/dist/views/mobile/MobileWeek.svelte +85 -35
  39. package/dist/views/planner/PlannerDay.svelte +116 -60
  40. package/dist/views/planner/PlannerWeek.svelte +228 -166
  41. package/dist/views/shared/context.svelte.d.ts +47 -0
  42. package/dist/views/shared/context.svelte.js +47 -0
  43. package/dist/views/shared/format.d.ts +19 -0
  44. package/dist/views/shared/format.js +46 -0
  45. package/dist/views/shared/index.d.ts +4 -0
  46. package/dist/views/shared/index.js +2 -0
  47. package/package.json +9 -2
@@ -108,6 +108,8 @@
108
108
  maxDuration?: number;
109
109
  /** Specific dates to disable (greyed-out, no event creation). */
110
110
  disabledDates?: Date[];
111
+ /** Compact mode: use minimal text-row rendering in Agenda views (dot + time + title). */
112
+ compact?: boolean;
111
113
  /**
112
114
  * Mobile mode.
113
115
  * - `'auto'` (default): detect via viewport width (< 768 px)
@@ -123,6 +125,16 @@
123
125
  empty?: Snippet;
124
126
  /** Custom day header snippet. Receives { date, isToday, dayName }. */
125
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]>;
126
138
 
127
139
  // ── Callbacks ──
128
140
  oneventclick?: (event: TimelineEvent) => void;
@@ -171,10 +183,13 @@
171
183
  minDuration,
172
184
  maxDuration,
173
185
  disabledDates,
186
+ compact = false,
174
187
  mobile: mobileProp = 'auto',
175
188
  event: eventSnippet,
176
189
  empty: emptySnippet,
177
190
  dayHeader: dayHeaderSnippet,
191
+ header: headerSnippet,
192
+ navigation: navigationSnippet,
178
193
  oneventclick,
179
194
  oneventcreate,
180
195
  oneventmove,
@@ -187,20 +202,12 @@
187
202
  const effectiveCreate = $derived(readOnly ? undefined : oneventcreate);
188
203
  const effectiveMove = $derived(readOnly ? undefined : oneventmove);
189
204
 
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
- });
205
+ // ── Mobile detection (container-based, not viewport) ──
206
+ let containerWidth = $state(0);
207
+ const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
201
208
 
202
209
  const useMobile = $derived(
203
- mobileProp === 'auto' ? isMobileViewport : Boolean(mobileProp)
210
+ mobileProp === 'auto' ? isMobileContainer : Boolean(mobileProp)
204
211
  );
205
212
 
206
213
  // ── Smart auto-theme ──
@@ -208,15 +215,27 @@
208
215
  // probe the host page on mount and reactively watch for host theme changes.
209
216
  let calEl: HTMLElement | undefined = $state();
210
217
  let probedTheme = $state('');
218
+ // Only delay rendering when auto-probe is actually needed
219
+ const needsProbe = $derived(theme === auto && autoTheme !== false);
211
220
 
212
221
  onMount(() => {
213
222
  if (!calEl) return;
214
- // Only probe when using the auto preset (empty string)
215
- const isAuto = theme === auto && autoTheme !== false;
216
- if (!isAuto) 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();
217
233
 
218
234
  const opts: AutoThemeOptions = typeof autoTheme === 'object' ? autoTheme : {};
219
- return observeHostTheme(calEl, (vars) => { probedTheme = vars; }, opts);
235
+ const stopTheme = observeHostTheme(calEl, (vars) => {
236
+ probedTheme = vars;
237
+ }, opts);
238
+ return () => { ro.disconnect(); stopTheme?.(); };
220
239
  });
221
240
 
222
241
  /** Effective theme: user-provided takes priority, otherwise probed auto. */
@@ -236,7 +255,7 @@
236
255
 
237
256
  // ── Drag commit handler ──
238
257
  // Views call this on pointer-up to process drag results.
239
- function commitDrag(): void {
258
+ async function commitDrag(): Promise<void> {
240
259
  if (readOnly) { drag.cancel(); return; }
241
260
  const mode = drag.mode;
242
261
  const payload = drag.commit();
@@ -288,55 +307,68 @@
288
307
  }
289
308
 
290
309
  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);
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
+ }
294
322
  } else if (mode === 'create') {
295
323
  effectiveCreate?.({ start, end });
296
324
  }
297
325
  }
298
326
 
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
327
  // ── Load range signal ──
333
328
  // Views can write a wider range here to override the default viewState.range.
334
329
  // This lets infinite-scroll views (PlannerWeek, PlannerDay) declare their
335
330
  // buffer needs without directly calling store.load().
336
331
  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; },
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 showNavigation() { return showNavigation; },
357
+ get equalDays() { return equalDays; },
358
+ get showDates() { return showDates; },
359
+ get hideDays() { return hideDays; },
360
+ get blockedSlots() { return blockedSlots; },
361
+ get dayHeaderSnippet() { return dayHeaderSnippet; },
362
+ get minDuration() { return minDuration; },
363
+ get maxDuration() { return maxDuration; },
364
+ get disabledDates() { return disabledDates; },
365
+ get mobile() { return useMobile; },
366
+ get autoHeight() { return heightProp === 'auto'; },
367
+ get compact() { return compact; },
368
+
369
+ // Load range (read/write)
370
+ get loadRange() { return viewLoadRange; },
371
+ setLoadRange(range: { start: Date; end: Date } | null) { viewLoadRange = range; },
340
372
  });
341
373
 
342
374
  // ── Load events when effective range changes ──
@@ -344,6 +376,9 @@
344
376
  const range = viewLoadRange ?? viewState.range;
345
377
  store.load({ start: range.start, end: range.end });
346
378
  });
379
+ // Eager initial load — $effect runs after paint, but for sync adapters
380
+ // (memory, recurring) this resolves in the same microtask.
381
+ untrack(() => store.load({ start: viewState.range.start, end: viewState.range.end }));
347
382
 
348
383
  // Keep active view in sync when external view prop changes after mount.
349
384
  $effect(() => {
@@ -378,6 +413,7 @@
378
413
  onviewchange?.(viewState.view);
379
414
  });
380
415
 
416
+
381
417
  // ── Resolve active view ──
382
418
  // When mobile is active, Planner views get remapped to Mobile variants.
383
419
  // Agenda views stay as Agenda — they're already list-based and will adapt
@@ -448,6 +484,29 @@
448
484
  const { start, end } = viewState.range;
449
485
  return now >= start.getTime() && now < end.getTime();
450
486
  });
487
+
488
+ /** Header context for custom header snippet */
489
+ const headerCtx = $derived({
490
+ dateLabel,
491
+ mode: viewState.mode,
492
+ modes,
493
+ switchMode,
494
+ prev: () => viewState.prev(),
495
+ next: () => viewState.next(),
496
+ goToday: () => viewState.goToday(),
497
+ isViewOnToday: viewIncludesToday,
498
+ focusDate: viewState.focusDate,
499
+ });
500
+
501
+ /** Navigation context for custom navigation snippet */
502
+ const navCtx = $derived({
503
+ prev: () => viewState.prev(),
504
+ next: () => viewState.next(),
505
+ goToday: () => viewState.goToday(),
506
+ isViewOnToday: viewIncludesToday,
507
+ focusDate: viewState.focusDate,
508
+ mode: viewState.mode,
509
+ });
451
510
  </script>
452
511
 
453
512
  <div
@@ -460,11 +519,17 @@
460
519
  dir={dir}
461
520
  lang={locale}
462
521
  >
522
+ <!-- ─── Custom header snippet (replaces all chrome) ─── -->
523
+ {#if headerSnippet}
524
+ {@render headerSnippet(headerCtx)}
525
+
463
526
  <!-- ─── Mobile header (flow layout, no absolute) ─── -->
464
- {#if useMobile}
527
+ {:else if useMobile}
465
528
  <div class="cal-m-hd">
466
529
  <div class="cal-m-left">
467
- {#if showNavigation}
530
+ {#if navigationSnippet}
531
+ {@render navigationSnippet(navCtx)}
532
+ {:else if showNavigation}
468
533
  <button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : L.previousWeek}>
469
534
  <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
535
  </button>
@@ -489,7 +554,7 @@
489
554
  <span class="cal-m-title">{dateLabel}</span>
490
555
 
491
556
  <div class="cal-m-right">
492
- {#if showNavigation}
557
+ {#if !navigationSnippet && showNavigation}
493
558
  <button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : L.nextWeek}>
494
559
  <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
560
  </button>
@@ -498,29 +563,31 @@
498
563
  </div>
499
564
 
500
565
  <!-- Today pill — floats below header, doesn't affect header flow -->
501
- {#if showNavigation && !viewIncludesToday}
566
+ {#if !navigationSnippet && showNavigation && !viewIncludesToday}
502
567
  <div class="cal-m-today-bar">
503
568
  <button class="cal-m-today" onclick={() => viewState.goToday()}>
504
569
  {L.today}
505
570
  </button>
506
571
  </div>
507
572
  {/if}
508
- {/if}
509
573
 
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>
574
+ <!-- ─── Desktop chrome ─── -->
575
+ {:else}
576
+ <!-- Floating mode pills (hidden for Agenda views) -->
577
+ {#if showModePills && modes.length > 1 && activeView?.label !== 'Agenda'}
578
+ <div class="cal-pills" role="group" aria-label={L.viewMode}>
579
+ {#each modes as g}
580
+ <button
581
+ class="cal-pill"
582
+ class:cal-pill--active={viewState.mode === g}
583
+ aria-pressed={viewState.mode === g}
584
+ onclick={() => switchMode(g)}
585
+ >
586
+ {g === 'day' ? L.day : L.week}
587
+ </button>
588
+ {/each}
589
+ </div>
590
+ {/if}
524
591
  {/if}
525
592
 
526
593
  <div class="cal-body">
@@ -554,19 +621,23 @@
554
621
  <style>
555
622
  .cal {
556
623
  position: relative;
624
+ width: 100%;
625
+ min-width: 0;
557
626
  height: var(--cal-h, 600px);
558
- background: var(--dt-bg, #0b0e14);
627
+ background: var(--dt-bg, inherit);
559
628
  border-radius: var(--cal-r, 12px);
560
629
  overflow: clip;
561
630
  display: flex;
562
631
  flex-direction: column;
563
632
  border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
633
+ box-sizing: border-box;
564
634
  }
565
635
  .cal--auto {
566
636
  height: auto;
567
637
  overflow: visible;
568
638
  }
569
639
 
640
+
570
641
  /* ── Floating pills ── */
571
642
  .cal-pills {
572
643
  position: absolute;
@@ -576,7 +647,7 @@
576
647
  z-index: 20;
577
648
  display: flex;
578
649
  gap: 2px;
579
- background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
650
+ background: color-mix(in srgb, var(--dt-surface, var(--dt-bg, #ffffff)) 85%, transparent);
580
651
  backdrop-filter: blur(6px);
581
652
  -webkit-backdrop-filter: blur(6px);
582
653
  border-radius: 8px;
@@ -589,7 +660,7 @@
589
660
  background: transparent;
590
661
  color: var(--dt-text-2, rgba(148, 163, 184, 0.55));
591
662
  cursor: pointer;
592
- font: 600 11px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
663
+ font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
593
664
  padding: 6px 12px;
594
665
  border-radius: 6px;
595
666
  letter-spacing: 0.04em;
@@ -602,12 +673,12 @@
602
673
  }
603
674
 
604
675
  .cal-pill--active {
605
- background: var(--dt-accent, #ef4444);
676
+ background: var(--dt-accent, #2563eb);
606
677
  color: var(--dt-btn-text, #fff);
607
678
  }
608
679
 
609
680
  .cal-pill:focus-visible {
610
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
681
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
611
682
  outline-offset: 2px;
612
683
  }
613
684
 
@@ -626,7 +697,7 @@
626
697
  align-items: center;
627
698
  justify-content: center;
628
699
  height: 100%;
629
- font: 400 13px / 1 var(--dt-sans, 'Outfit', system-ui, sans-serif);
700
+ font: 400 13px / 1 var(--dt-sans, system-ui, sans-serif);
630
701
  color: var(--dt-text-3, rgba(100, 116, 139, 0.55));
631
702
  }
632
703
 
@@ -639,7 +710,7 @@
639
710
  background: linear-gradient(
640
711
  90deg,
641
712
  transparent 0%,
642
- var(--dt-accent, #ef4444) 50%,
713
+ var(--dt-accent, #2563eb) 50%,
643
714
  transparent 100%
644
715
  );
645
716
  animation: cal-slide 1.2s ease-in-out infinite;
@@ -696,14 +767,14 @@
696
767
  background: var(--dt-accent-dim, rgba(239, 68, 68, 0.12));
697
768
  }
698
769
  .cal-m-nav:focus-visible {
699
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
770
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
700
771
  outline-offset: 2px;
701
772
  }
702
773
 
703
774
  .cal-m-pills {
704
775
  display: flex;
705
776
  gap: 2px;
706
- background: color-mix(in srgb, var(--dt-surface, #10141c) 85%, transparent);
777
+ background: color-mix(in srgb, var(--dt-surface, var(--dt-bg, #ffffff)) 85%, transparent);
707
778
  border-radius: 8px;
708
779
  padding: 2px;
709
780
  border: 1px solid var(--dt-border, rgba(148, 163, 184, 0.07));
@@ -726,7 +797,7 @@
726
797
  color: var(--dt-text, rgba(226, 232, 240, 0.85));
727
798
  }
728
799
  .cal-m-pill--active {
729
- background: var(--dt-accent, #ef4444);
800
+ background: var(--dt-accent, #2563eb);
730
801
  color: var(--dt-btn-text, #fff);
731
802
  }
732
803
 
@@ -750,8 +821,8 @@
750
821
 
751
822
  .cal-m-today {
752
823
  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);
824
+ color: var(--dt-accent, #2563eb);
825
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 10%, transparent);
755
826
  border: none;
756
827
  padding: 5px 10px;
757
828
  border-radius: 6px;
@@ -764,13 +835,13 @@
764
835
  flex-shrink: 0;
765
836
  }
766
837
  .cal-m-today:hover {
767
- background: color-mix(in srgb, var(--dt-accent, #ef4444) 18%, transparent);
838
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 18%, transparent);
768
839
  }
769
840
  .cal-m-today:active {
770
- background: color-mix(in srgb, var(--dt-accent, #ef4444) 25%, transparent);
841
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 25%, transparent);
771
842
  }
772
843
  .cal-m-today:focus-visible {
773
- outline: 2px solid color-mix(in srgb, var(--dt-accent, #ef4444) 55%, transparent);
844
+ outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
774
845
  outline-offset: 2px;
775
846
  }
776
847
  </style>
@@ -72,6 +72,8 @@ interface Props {
72
72
  maxDuration?: number;
73
73
  /** Specific dates to disable (greyed-out, no event creation). */
74
74
  disabledDates?: Date[];
75
+ /** Compact mode: use minimal text-row rendering in Agenda views (dot + time + title). */
76
+ compact?: boolean;
75
77
  /**
76
78
  * Mobile mode.
77
79
  * - `'auto'` (default): detect via viewport width (< 768 px)
@@ -89,6 +91,16 @@ interface Props {
89
91
  isToday: boolean;
90
92
  dayName: string;
91
93
  }]>;
94
+ /**
95
+ * Replace the entire header chrome (date label + mode pills + nav arrows).
96
+ * Receives context: { dateLabel, mode, modes, switchMode, prev, next, goToday, isViewOnToday, focusDate }.
97
+ */
98
+ header?: Snippet<[import('../headless/types.js').HeaderContext]>;
99
+ /**
100
+ * Replace just the navigation controls (arrows + today button).
101
+ * Receives context: { prev, next, goToday, isViewOnToday, focusDate, mode }.
102
+ */
103
+ navigation?: Snippet<[import('../headless/types.js').NavigationContext]>;
92
104
  oneventclick?: (event: TimelineEvent) => void;
93
105
  oneventcreate?: (range: {
94
106
  start: Date;
@@ -5,5 +5,7 @@ export type { DaySegment } from './time.js';
5
5
  export { setDefaultLocale, getDefaultLocale, is24HourLocale, fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, defaultLabels, setLabels, resetLabels, getLabels, } from './locale.js';
6
6
  export type { CalendarLabels } from './locale.js';
7
7
  export { toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, } from './timezone.js';
8
- export type { TimelineEvent, BlockedSlot, } from './types.js';
8
+ export type { TimelineEvent, BlockedSlot, EventStatus, } from './types.js';
9
9
  export { generatePalette, extractAccent, VIVID_PALETTE } from './palette.js';
10
+ export { createTextMeasure, initTextMeasure } from './measure.js';
11
+ export type { TextMeasure, TextMeasureOptions, ContentFit } from './measure.js';
@@ -9,3 +9,5 @@ export { setDefaultLocale, getDefaultLocale, is24HourLocale, fmtH, fmtTime, fmtD
9
9
  export { toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, } from './timezone.js';
10
10
  // Palette
11
11
  export { generatePalette, extractAccent, VIVID_PALETTE } from './palette.js';
12
+ // Text measurement (optional Pretext integration)
13
+ export { createTextMeasure, initTextMeasure } from './measure.js';
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Text measurement utilities powered by Pretext.
3
+ *
4
+ * Provides DOM-free text measurement for calendar event blocks.
5
+ * Pretext is an optional peer dependency — if not installed,
6
+ * these functions degrade gracefully to simple heuristics.
7
+ *
8
+ * Usage:
9
+ * const m = createTextMeasure({ font: '500 12px Inter' });
10
+ * const fits = m.fits('Meeting with team', 120, 16); // width, lineHeight
11
+ * const { height, lineCount } = m.measure('Long event title...', 120, 16);
12
+ * m.clear(); // release caches
13
+ */
14
+ export interface TextMeasure {
15
+ /**
16
+ * Measure text height at a given width.
17
+ * Returns { height, lineCount }.
18
+ */
19
+ measure(text: string, maxWidth: number, lineHeight: number): {
20
+ height: number;
21
+ lineCount: number;
22
+ };
23
+ /**
24
+ * Check if text fits in a single line at the given width.
25
+ */
26
+ fits(text: string, maxWidth: number, lineHeight: number): boolean;
27
+ /**
28
+ * Measure multiple texts and return their combined height.
29
+ * Useful for checking if title + subtitle + tags fit in a block.
30
+ */
31
+ measureStack(items: Array<{
32
+ text: string;
33
+ font?: string;
34
+ lineHeight?: number;
35
+ }>, maxWidth: number, gap?: number): {
36
+ height: number;
37
+ breakdown: Array<{
38
+ height: number;
39
+ lineCount: number;
40
+ }>;
41
+ };
42
+ /**
43
+ * Given available height and width, decide which content layers fit.
44
+ * Returns a visibility map for common event fields.
45
+ */
46
+ fitContent(opts: {
47
+ title: string;
48
+ subtitle?: string;
49
+ location?: string;
50
+ time?: string;
51
+ tags?: string[];
52
+ maxWidth: number;
53
+ maxHeight: number;
54
+ }): ContentFit;
55
+ /** Release internal caches. */
56
+ clear(): void;
57
+ /** Whether Pretext is available (true) or using heuristic fallback (false). */
58
+ readonly available: boolean;
59
+ }
60
+ export interface ContentFit {
61
+ /** Title always shown */
62
+ title: true;
63
+ /** Number of lines the title takes */
64
+ titleLines: number;
65
+ /** Whether subtitle fits */
66
+ subtitle: boolean;
67
+ /** Whether location fits */
68
+ location: boolean;
69
+ /** Whether time range fits */
70
+ time: boolean;
71
+ /** Whether tags fit */
72
+ tags: boolean;
73
+ /** Total height of all visible content */
74
+ totalHeight: number;
75
+ }
76
+ export interface TextMeasureOptions {
77
+ /** CSS font shorthand for titles (e.g. '500 12px Inter') */
78
+ titleFont?: string;
79
+ /** CSS font shorthand for secondary text (subtitle, location, time) */
80
+ secondaryFont?: string;
81
+ /** CSS font shorthand for tags */
82
+ tagFont?: string;
83
+ /** Default line height for titles */
84
+ titleLineHeight?: number;
85
+ /** Default line height for secondary text */
86
+ secondaryLineHeight?: number;
87
+ /** Gap between content rows in px */
88
+ contentGap?: number;
89
+ }
90
+ /**
91
+ * Create a text measure instance.
92
+ * Lazily loads Pretext on first use. Falls back to char-width heuristics if unavailable.
93
+ */
94
+ export declare function createTextMeasure(opts?: TextMeasureOptions): TextMeasure;
95
+ /**
96
+ * Initialize Pretext for text measurement.
97
+ * Call this once in your app's entry point (e.g., +layout.svelte onMount).
98
+ * If not called, measurement falls back to character-width heuristics.
99
+ *
100
+ * Usage:
101
+ * import { initTextMeasure } from '@nomideusz/svelte-calendar';
102
+ * onMount(() => initTextMeasure());
103
+ */
104
+ export declare function initTextMeasure(): Promise<boolean>;