@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
@@ -6,16 +6,16 @@
6
6
  Events are positioned as horizontal cards with overlap support.
7
7
  -->
8
8
  <script lang="ts">
9
- import { getContext, onMount, tick, untrack } from 'svelte';
9
+ import { getContext, onMount, tick, untrack, type Snippet } from 'svelte';
10
10
  import { fly } from 'svelte/transition';
11
11
  import { createClock } from '../../core/clock.svelte.js';
12
- import type { TimelineEvent } from '../../core/types.js';
12
+ import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
13
13
  import type { DragState } from '../../engine/drag.svelte.js';
14
14
  import type { ViewState } from '../../engine/view-state.svelte.js';
15
15
  import { DAY_MS, HOUR_MS, sod } from '../../core/time.js';
16
16
  import { isAllDay, isMultiDay, segmentForDay } from '../../core/time.js';
17
17
  import type { DaySegment } from '../../core/time.js';
18
- import { fmtH, fmtTime } from '../../core/locale.js';
18
+ import { fmtH, fmtTime, weekdayShort } from '../../core/locale.js';
19
19
  import { getLabels } from '../../core/locale.js';
20
20
 
21
21
  const L = $derived(getLabels());
@@ -62,8 +62,27 @@
62
62
  const commitDragCtx = getContext<() => void>('calendar:commitDrag') as (() => void) | undefined;
63
63
  const snapIntervalCtx = getContext<{ current: number }>('calendar:snapInterval');
64
64
  const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
65
+ const loadRangeCtx = getContext<{ current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void }>('calendar:loadRange') as { current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void } | undefined;
65
66
 
66
67
  const clock = createClock();
68
+ const showNavCtx = getContext<{ current: boolean }>('calendar:showNavigation') as { current: boolean } | undefined;
69
+ const showDatesCtx = getContext<{ current: boolean }>('calendar:showDates') as { current: boolean } | undefined;
70
+ const showNav = $derived(showNavCtx?.current ?? true);
71
+ const showDates = $derived(showDatesCtx?.current ?? true);
72
+
73
+ // ── New feature contexts ──
74
+ const blockedSlotsCtx = getContext<{ current: BlockedSlot[] | undefined }>('calendar:blockedSlots') as { current: BlockedSlot[] | undefined } | undefined;
75
+ const dayHeaderSnippetCtx = getContext<{ current: Snippet<[{ date: Date; isToday: boolean; dayName: string }]> | undefined }>('calendar:dayHeaderSnippet') as { current: Snippet<[{ date: Date; isToday: boolean; dayName: string }]> | undefined } | undefined;
76
+ const minDurationCtx = getContext<{ current: number | undefined }>('calendar:minDuration') as { current: number | undefined } | undefined;
77
+ const callbacksCtx = getContext<{ oneventhover?: (event: TimelineEvent) => void }>('calendar:callbacks') as { oneventhover?: (event: TimelineEvent) => void } | undefined;
78
+ const disabledDatesCtx = getContext<{ current: Date[] | undefined }>('calendar:disabledDates') as { current: Date[] | undefined } | undefined;
79
+
80
+ const blockedSlots = $derived(blockedSlotsCtx?.current);
81
+ const dayHeaderSnippet = $derived(dayHeaderSnippetCtx?.current);
82
+ const minDuration = $derived(minDurationCtx?.current);
83
+ const oneventhover = $derived(callbacksCtx?.oneventhover);
84
+ const disabledDates = $derived(disabledDatesCtx?.current);
85
+ const disabledSet = $derived(new Set(disabledDates?.map(d => sod(d.getTime())) ?? []));
67
86
 
68
87
  // ─── State ──────────────────────────────────────────
69
88
  let following = $state(true);
@@ -96,16 +115,29 @@
96
115
  const DAY_GAP = 2;
97
116
 
98
117
  const dateLabel = $derived(
99
- new Date(visibleDayMs).toLocaleDateString(locale ?? 'en-US', {
100
- weekday: 'long',
101
- month: 'long',
102
- day: 'numeric',
103
- })
118
+ showDates
119
+ ? new Date(visibleDayMs).toLocaleDateString(locale ?? 'en-US', {
120
+ weekday: 'long',
121
+ month: 'long',
122
+ day: 'numeric',
123
+ })
124
+ : new Date(visibleDayMs).toLocaleDateString(locale ?? 'en-US', {
125
+ weekday: 'long',
126
+ })
104
127
  );
105
128
 
106
129
  const count = 1 + 2 * BUFFER_DAYS;
107
130
  const origin = $derived(internalCenterMs - BUFFER_DAYS * DAY_MS);
108
131
 
132
+ // ─── Declare load range for entire visible buffer ────────
133
+ $effect(() => {
134
+ if (!loadRangeCtx) return;
135
+ const rangeStart = new Date(internalCenterMs - BUFFER_DAYS * DAY_MS);
136
+ const rangeEnd = new Date(internalCenterMs + (BUFFER_DAYS + 1) * DAY_MS);
137
+ loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
138
+ return () => loadRangeCtx.set(null);
139
+ });
140
+
109
141
  // Auto-calculate hourWidth — minimum 60px so hours stay readable.
110
142
  // When 24h × 60px exceeds the container, the timeline scrolls horizontally.
111
143
  const MIN_HOUR_W = 60;
@@ -398,6 +430,15 @@
398
430
  }
399
431
 
400
432
  // ─── Click-to-create ────────────────────────────────
433
+ function isBlockedAt(dayMs: number, hour: number): boolean {
434
+ if (!blockedSlots?.length) return false;
435
+ const jsDay = new Date(dayMs).getDay();
436
+ const isoDay = jsDay === 0 ? 7 : jsDay;
437
+ return blockedSlots.some(slot => {
438
+ if (slot.day && slot.day !== isoDay) return false;
439
+ return hour >= slot.start && hour < slot.end;
440
+ });
441
+ }
401
442
  function handleTrackClick(e: MouseEvent) {
402
443
  if (wasDragging) { wasDragging = false; return; }
403
444
  if (!oneventcreate || readOnly) return;
@@ -408,10 +449,16 @@
408
449
 
409
450
  for (const d of days) {
410
451
  if (clickX >= d.x && clickX < d.x + dayWidth) {
452
+ // Check disabled dates
453
+ if (disabledSet.has(d.ms)) return;
411
454
  const frac = (clickX - d.x) / dayWidth;
412
- const hour = Math.floor(startHour + frac * hourCount);
455
+ const clickHour = startHour + frac * hourCount;
456
+ // Check blocked slots
457
+ if (isBlockedAt(d.ms, clickHour)) return;
458
+ const hour = Math.floor(clickHour);
459
+ const durMin = minDuration ? Math.max(60, minDuration) : 60;
413
460
  const start = new Date(d.ms + hour * HOUR_MS);
414
- const end = new Date(d.ms + (hour + 1) * HOUR_MS);
461
+ const end = new Date(start.getTime() + durMin * 60_000);
415
462
  oneventcreate({ start, end });
416
463
  return;
417
464
  }
@@ -498,6 +545,7 @@
498
545
  class="fs-day"
499
546
  class:fs-today={d.today}
500
547
  class:fs-past={d.past}
548
+ class:fs-disabled={disabledSet.has(d.ms)}
501
549
  style:left="{d.x}px"
502
550
  style:width="{dayWidth}px"
503
551
  >
@@ -509,6 +557,37 @@
509
557
  </div>
510
558
  <div class="fs-tick fs-tick--half" style:left="{x + hourWidth * 0.5}px"></div>
511
559
  {/each}
560
+
561
+ <!-- Blocked slot overlays -->
562
+ {#if blockedSlots?.length}
563
+ {@const jsDay = new Date(d.ms).getDay()}
564
+ {@const isoDay = jsDay === 0 ? 7 : jsDay}
565
+ {#each blockedSlots as slot}
566
+ {#if !slot.day || slot.day === isoDay}
567
+ {@const s = Math.max(slot.start, startHour)}
568
+ {@const e = Math.min(slot.end, endHour)}
569
+ {#if e > s}
570
+ <div
571
+ class="fs-blocked"
572
+ style:left="{(s - startHour) * hourWidth}px"
573
+ style:width="{(e - s) * hourWidth}px"
574
+ aria-label={slot.label || 'Unavailable'}
575
+ >
576
+ {#if slot.label}
577
+ <span class="fs-blocked-label">{slot.label}</span>
578
+ {/if}
579
+ </div>
580
+ {/if}
581
+ {/if}
582
+ {/each}
583
+ {/if}
584
+
585
+ <!-- Custom day header snippet -->
586
+ {#if dayHeaderSnippet}
587
+ <div class="fs-day-header-custom">
588
+ {@render dayHeaderSnippet({ date: new Date(d.ms), isToday: d.today, dayName: weekdayShort(d.ms, locale) })}
589
+ </div>
590
+ {/if}
512
591
  </div>
513
592
  {/each}
514
593
 
@@ -534,6 +613,7 @@
534
613
  tabindex="0"
535
614
  aria-label="{p.ev.title}{p.isCurrent ? ` (${L.inProgress})` : ''}"
536
615
  onpointerdown={(e) => onEventPointerDown(e, p.ev)}
616
+ onpointerenter={() => oneventhover?.(p.ev)}
537
617
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oneventclick?.(p.ev); } }}
538
618
  >
539
619
  <div class="fs-ev-inner">
@@ -589,6 +669,7 @@
589
669
 
590
670
  <div class="fs-date-label">{dateLabel}</div>
591
671
 
672
+ {#if showNav}
592
673
  <nav class="fs-nav" aria-label={L.dayNavigation}>
593
674
  <button
594
675
  class="fs-nav-pill fs-nav-today"
@@ -614,6 +695,7 @@
614
695
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
615
696
  </button>
616
697
  </nav>
698
+ {/if}
617
699
  </div>
618
700
 
619
701
  <style>
@@ -662,6 +744,58 @@
662
744
  .fs-today { background: var(--dt-today-bg, rgba(239, 68, 68, 0.025)); }
663
745
  .fs-past { opacity: 0.7; }
664
746
 
747
+ /* ─── Disabled day ───────────────────────────────── */
748
+ .fs-disabled {
749
+ opacity: 0.35;
750
+ background: repeating-linear-gradient(
751
+ 45deg,
752
+ transparent,
753
+ transparent 6px,
754
+ var(--dt-border, rgba(148, 163, 184, 0.07)) 6px,
755
+ var(--dt-border, rgba(148, 163, 184, 0.07)) 7px
756
+ ) !important;
757
+ }
758
+
759
+ /* ─── Blocked slot overlay ───────────────────────── */
760
+ .fs-blocked {
761
+ position: absolute;
762
+ top: 18px;
763
+ bottom: 0;
764
+ z-index: 3;
765
+ background: repeating-linear-gradient(
766
+ -45deg,
767
+ color-mix(in srgb, var(--dt-text, rgba(148, 163, 184, 0.85)) 4%, transparent),
768
+ color-mix(in srgb, var(--dt-text, rgba(148, 163, 184, 0.85)) 4%, transparent) 4px,
769
+ transparent 4px,
770
+ transparent 8px
771
+ );
772
+ border-radius: 4px;
773
+ pointer-events: none;
774
+ display: flex;
775
+ align-items: flex-end;
776
+ justify-content: center;
777
+ padding-bottom: 6px;
778
+ }
779
+
780
+ .fs-blocked-label {
781
+ font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
782
+ color: var(--dt-text-3, rgba(100, 116, 139, 0.55));
783
+ text-transform: uppercase;
784
+ letter-spacing: 0.04em;
785
+ white-space: nowrap;
786
+ }
787
+
788
+ /* ─── Custom day header ──────────────────────────── */
789
+ .fs-day-header-custom {
790
+ position: absolute;
791
+ top: 16px;
792
+ left: 50%;
793
+ transform: translateX(-50%);
794
+ z-index: 4;
795
+ pointer-events: auto;
796
+ white-space: nowrap;
797
+ }
798
+
665
799
  /* ─── Hour ticks ─────────────────────────────────── */
666
800
  .fs-tick {
667
801
  position: absolute;
@@ -9,12 +9,11 @@
9
9
  • Generous whitespace, thin dividers, minimal chrome.
10
10
  -->
11
11
  <script lang="ts">
12
- import { getContext, onMount, tick, untrack } from 'svelte';
12
+ import { getContext, onMount, tick, untrack, type Snippet } from 'svelte';
13
13
  import { createClock } from '../../core/clock.svelte.js';
14
- import type { TimelineEvent } from '../../core/types.js';
14
+ import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
15
15
  import type { DragState } from '../../engine/drag.svelte.js';
16
16
  import type { ViewState } from '../../engine/view-state.svelte.js';
17
- import type { EventStore } from '../../engine/event-store.svelte.js';
18
17
  import { DAY_MS, HOUR_MS, sod } from '../../core/time.js';
19
18
  import { startOfWeek as sowFn, fractionalHour, isAllDay, isMultiDay, segmentForDay } from '../../core/time.js';
20
19
  import type { DaySegment } from '../../core/time.js';
@@ -53,9 +52,31 @@
53
52
  const drag = getContext<DragState>('calendar:drag') as DragState | undefined;
54
53
  const commitDragCtx = getContext<() => void>('calendar:commitDrag') as (() => void) | undefined;
55
54
  const viewState = getContext<ViewState>('calendar:viewState') as ViewState | undefined;
56
- const storeCtx = getContext<{ current: EventStore }>('calendar:store') as { current: EventStore } | undefined;
55
+ const loadRangeCtx = getContext<{ current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void }>('calendar:loadRange') as { current: { start: Date; end: Date } | null; set: (r: { start: Date; end: Date } | null) => void } | undefined;
57
56
 
58
57
  const clock = createClock();
58
+ const showNavCtx = getContext<{ current: boolean }>('calendar:showNavigation') as { current: boolean } | undefined;
59
+ const equalDaysCtx = getContext<{ current: boolean }>('calendar:equalDays') as { current: boolean } | undefined;
60
+ const showDatesCtx = getContext<{ current: boolean }>('calendar:showDates') as { current: boolean } | undefined;
61
+ const hideDaysCtx = getContext<{ current: number[] | undefined }>('calendar:hideDays') as { current: number[] | undefined } | undefined;
62
+ const showNav = $derived(showNavCtx?.current ?? true);
63
+ const equalDays = $derived(equalDaysCtx?.current ?? false);
64
+ const showDates = $derived(showDatesCtx?.current ?? true);
65
+ const hideDays = $derived(hideDaysCtx?.current);
66
+
67
+ // ── New feature contexts ──
68
+ const blockedSlotsCtx = getContext<{ current: BlockedSlot[] | undefined }>('calendar:blockedSlots') as { current: BlockedSlot[] | undefined } | undefined;
69
+ const dayHeaderSnippetCtx = getContext<{ current: Snippet<[{ date: Date; isToday: boolean; dayName: string }]> | undefined }>('calendar:dayHeaderSnippet') as { current: Snippet<[{ date: Date; isToday: boolean; dayName: string }]> | undefined } | undefined;
70
+ const minDurationCtx = getContext<{ current: number | undefined }>('calendar:minDuration') as { current: number | undefined } | undefined;
71
+ const callbacksCtx = getContext<{ oneventhover?: (event: TimelineEvent) => void }>('calendar:callbacks') as { oneventhover?: (event: TimelineEvent) => void } | undefined;
72
+ const disabledDatesCtx = getContext<{ current: Date[] | undefined }>('calendar:disabledDates') as { current: Date[] | undefined } | undefined;
73
+
74
+ const blockedSlots = $derived(blockedSlotsCtx?.current);
75
+ const dayHeaderSnippet = $derived(dayHeaderSnippetCtx?.current);
76
+ const minDuration = $derived(minDurationCtx?.current);
77
+ const oneventhover = $derived(callbacksCtx?.oneventhover);
78
+ const disabledDates = $derived(disabledDatesCtx?.current);
79
+ const disabledSet = $derived(new Set(disabledDates?.map(d => sod(d.getTime())) ?? []));
59
80
 
60
81
  // ─── Infinite-scroll config ────────────────────────
61
82
  const BUFFER_WEEKS = 6; // weeks rendered on each side of anchor
@@ -82,18 +103,22 @@
82
103
 
83
104
  // ─── Derived ────────────────────────────────────────
84
105
  const todayMs = $derived(clock.today);
85
- const anchorWeekStart = $derived(sowFn(internalFocusMs, mondayStart));
86
-
87
- // ─── Preload events for entire visible range ────────
88
- // Calendar only loads a single week by default. The planner shows
89
- // BUFFER_WEEKS on each side, so we proactively load the full range
90
- // so events for non-current weeks are visible immediately.
106
+ const customDays = $derived(viewState?.dayCount ?? 7);
107
+ const anchorPeriodStart = $derived(
108
+ customDays === 7
109
+ ? sowFn(internalFocusMs, mondayStart)
110
+ : sod(internalFocusMs)
111
+ );
112
+
113
+ // ─── Declare load range for entire visible buffer ────────
114
+ // Instead of calling store.load() directly, we tell Calendar
115
+ // what range we need. Calendar's single $effect handles loading.
91
116
  $effect(() => {
92
- const store = storeCtx?.current;
93
- if (!store) return;
94
- const rangeStart = new Date(anchorWeekStart - BUFFER_WEEKS * 7 * DAY_MS);
95
- const rangeEnd = new Date(anchorWeekStart + (BUFFER_WEEKS + 1) * 7 * DAY_MS);
96
- store.load({ start: rangeStart, end: rangeEnd });
117
+ if (!loadRangeCtx) return;
118
+ const rangeStart = new Date(anchorPeriodStart - BUFFER_WEEKS * customDays * DAY_MS);
119
+ const rangeEnd = new Date(anchorPeriodStart + (BUFFER_WEEKS + 1) * customDays * DAY_MS);
120
+ loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
121
+ return () => loadRangeCtx.set(null);
97
122
  });
98
123
 
99
124
  // ─── Week data ──────────────────────────────────────
@@ -119,24 +144,20 @@
119
144
 
120
145
  const weeks = $derived.by(() => {
121
146
  const result: WeekRow[] = [];
122
- const currentWeekStart = sowFn(todayMs, mondayStart);
123
147
 
124
148
  for (let w = -BUFFER_WEEKS; w <= BUFFER_WEEKS; w++) {
125
- const weekStart = anchorWeekStart + w * 7 * DAY_MS;
126
- const isCurrent = weekStart === currentWeekStart;
149
+ const periodStart = anchorPeriodStart + w * customDays * DAY_MS;
150
+ const isCurrent = todayMs >= periodStart && todayMs < periodStart + customDays * DAY_MS;
127
151
  const days: DayCell[] = [];
128
152
 
129
- const firstMonth = new Date(weekStart).getMonth();
130
- const lastMonth = new Date(weekStart + 6 * DAY_MS).getMonth();
131
-
132
- for (let d = 0; d < 7; d++) {
133
- const ms = weekStart + d * DAY_MS;
153
+ for (let d = 0; d < customDays; d++) {
154
+ const ms = periodStart + d * DAY_MS;
134
155
  const date = new Date(ms);
135
156
  const dayNum = date.getDate();
136
157
  const dow = date.getDay();
137
158
  const isWeekend = dow === 0 || dow === 6;
138
159
  const isToday = ms === todayMs;
139
- const isPast = ms < todayMs;
160
+ const isPast = equalDays ? false : ms < todayMs;
140
161
  const isFirstOfMonth = dayNum === 1;
141
162
  const monthLabel = (d === 0 || isFirstOfMonth)
142
163
  ? monthLong(ms, locale).toUpperCase()
@@ -162,12 +183,24 @@
162
183
  days.push({ ms, dayNum, isToday, isPast, isWeekend, isFirstOfMonth, monthLabel, events: timedEvents, allDaySegments });
163
184
  }
164
185
 
165
- // Month label: show when first day of week is day 1-7
166
- const startDate = new Date(weekStart);
167
- const showMonth = startDate.getDate() <= 7;
168
- const monthLabel = showMonth ? monthLong(weekStart, locale).toUpperCase() : null;
186
+ // Month label: show when first day of period is day 1-7 (for 7-day), or first day of period (for custom)
187
+ const startDate = new Date(periodStart);
188
+ const showMonth = customDays === 7 ? startDate.getDate() <= 7 : startDate.getDate() <= customDays;
189
+ const monthLabel = showMonth ? monthLong(periodStart, locale).toUpperCase() : null;
169
190
 
170
- result.push({ weekStart, isCurrent, monthLabel, days });
191
+ result.push({ weekStart: periodStart, isCurrent, monthLabel, days });
192
+ }
193
+
194
+ // Filter hidden days if hideDays is set
195
+ if (hideDays?.length) {
196
+ for (const row of result) {
197
+ row.days = row.days.filter((d) => {
198
+ const isoDay = new Date(d.ms).getDay();
199
+ // Convert JS day (0=Sun) to ISO (7=Sun)
200
+ const iso = isoDay === 0 ? 7 : isoDay;
201
+ return !hideDays.includes(iso);
202
+ });
203
+ }
171
204
  }
172
205
 
173
206
  return result;
@@ -239,7 +272,7 @@
239
272
  }
240
273
 
241
274
  const shift = SHIFT_WEEKS * direction;
242
- internalFocusMs += shift * 7 * DAY_MS;
275
+ internalFocusMs += shift * customDays * DAY_MS;
243
276
  lastExternalMs = internalFocusMs;
244
277
  viewState?.setFocusDate(new Date(internalFocusMs));
245
278
 
@@ -310,8 +343,12 @@
310
343
  const target = e.target as HTMLElement;
311
344
  if (target.closest('.wg-ev')) return;
312
345
  if (readOnly || !oneventcreate) return;
346
+ // Check disabled dates
347
+ if (disabledSet.has(ms)) return;
348
+ // Check blocked slots (all-day block check: if entire day is blocked, prevent)
349
+ const durMin = minDuration ? Math.max(60, minDuration) : 60;
313
350
  const start = new Date(ms + 9 * HOUR_MS);
314
- const end = new Date(ms + 10 * HOUR_MS);
351
+ const end = new Date(start.getTime() + durMin * 60_000);
315
352
  oneventcreate({ start, end });
316
353
  }
317
354
 
@@ -416,8 +453,7 @@
416
453
  class="wg-cell"
417
454
  class:wg-cell--today={day.isToday}
418
455
  class:wg-cell--past={day.isPast}
419
- class:wg-cell--weekend={day.isWeekend}
420
- role="gridcell"
456
+ class:wg-cell--weekend={day.isWeekend} class:wg-cell--disabled={disabledSet.has(day.ms)} role="gridcell"
421
457
  tabindex="0"
422
458
  aria-label="{new Date(day.ms).toLocaleDateString(locale ?? 'en-US', { weekday: 'long', month: 'short', day: 'numeric' })}{day.isToday ? ` (${L.today.toLowerCase()})` : ''}, {L.nEvents(day.events.length)}"
423
459
  onclick={(e) => handleDayCellClick(day.ms, e)}
@@ -425,16 +461,40 @@
425
461
  >
426
462
  <!-- Day label in top-right corner -->
427
463
  <div class="wg-cell-hd" class:wg-cell-hd--today={day.isToday}>
428
- <span class="wg-day-num" class:wg-day-num--today={day.isToday}>
429
- {day.dayNum}
430
- </span>
464
+ {#if showDates}
465
+ <span class="wg-day-num" class:wg-day-num--today={day.isToday}>
466
+ {day.dayNum}
467
+ </span>
468
+ {/if}
431
469
  <span class="wg-day-wd">{weekdayShort(day.ms, locale)}</span>
432
470
  </div>
433
471
 
434
- {#if day.monthLabel}
472
+ {#if day.monthLabel && showDates}
435
473
  <span class="wg-cell-month">{day.monthLabel}</span>
436
474
  {/if}
437
475
 
476
+ <!-- Custom day header snippet -->
477
+ {#if dayHeaderSnippet}
478
+ <div class="wg-cell-custom-header">
479
+ {@render dayHeaderSnippet({ date: new Date(day.ms), isToday: day.isToday, dayName: weekdayShort(day.ms, locale) })}
480
+ </div>
481
+ {/if}
482
+
483
+ <!-- Blocked slots indicator -->
484
+ {#if blockedSlots?.length}
485
+ {@const jsDay = new Date(day.ms).getDay()}
486
+ {@const isoDay = jsDay === 0 ? 7 : jsDay}
487
+ {#each blockedSlots as slot}
488
+ {#if !slot.day || slot.day === isoDay}
489
+ <div class="wg-blocked" aria-label={slot.label || 'Unavailable'}>
490
+ {#if slot.label}
491
+ <span class="wg-blocked-label">{slot.label}</span>
492
+ {/if}
493
+ </div>
494
+ {/if}
495
+ {/each}
496
+ {/if}
497
+
438
498
  <!-- All-day / multi-day events -->
439
499
  {#if day.allDaySegments.length > 0}
440
500
  <div class="wg-allday">
@@ -478,8 +538,7 @@
478
538
  role="button"
479
539
  tabindex="0"
480
540
  aria-label="{ev.title}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
481
- onpointerdown={(e) => onEventPointerDown(e, ev)}
482
-
541
+ onpointerdown={(e) => onEventPointerDown(e, ev)} onpointerenter={() => oneventhover?.(ev)}
483
542
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev); } }}
484
543
  >
485
544
  <span class="wg-ev-time">{fmtAmPm(ev.start)}</span>
@@ -498,7 +557,7 @@
498
557
  {/each}
499
558
  </div>
500
559
 
501
- {#if scrolled}
560
+ {#if showNav && scrolled}
502
561
  <nav class="wg-nav" aria-label={L.weekNavigation}>
503
562
  <button
504
563
  class="wg-nav-pill"
@@ -590,8 +649,54 @@
590
649
  .wg-week--current .wg-cell--past { opacity: 0.8; }
591
650
  .wg-week--current .wg-cell--past:hover { opacity: 0.9; }
592
651
 
652
+ /* equalDays: when no cells are marked past, all are full brightness */
653
+
593
654
  .wg-cell--weekend { background: var(--dt-weekend-bg, rgba(0, 0, 0, 0.012)); }
594
655
 
656
+ /* ─── Disabled cell ──────────────────────────────── */
657
+ .wg-cell--disabled {
658
+ opacity: 0.35;
659
+ pointer-events: none;
660
+ background: repeating-linear-gradient(
661
+ 45deg,
662
+ transparent,
663
+ transparent 6px,
664
+ var(--dt-border, rgba(0, 0, 0, 0.06)) 6px,
665
+ var(--dt-border, rgba(0, 0, 0, 0.06)) 7px
666
+ ) !important;
667
+ }
668
+
669
+ /* ─── Blocked slot indicator ─────────────────────── */
670
+ .wg-blocked {
671
+ display: flex;
672
+ align-items: center;
673
+ gap: 3px;
674
+ padding: 2px 4px;
675
+ border-radius: 3px;
676
+ background: repeating-linear-gradient(
677
+ -45deg,
678
+ color-mix(in srgb, var(--dt-text, rgba(0,0,0,0.85)) 4%, transparent),
679
+ color-mix(in srgb, var(--dt-text, rgba(0,0,0,0.85)) 4%, transparent) 3px,
680
+ transparent 3px,
681
+ transparent 6px
682
+ );
683
+ margin-bottom: 2px;
684
+ min-height: 14px;
685
+ }
686
+
687
+ .wg-blocked-label {
688
+ font: 500 8px/1 var(--dt-sans, system-ui, sans-serif);
689
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.3));
690
+ text-transform: uppercase;
691
+ letter-spacing: 0.04em;
692
+ white-space: nowrap;
693
+ }
694
+
695
+ /* ─── Custom day header ──────────────────────────── */
696
+ .wg-cell-custom-header {
697
+ padding: 0 4px 2px;
698
+ }
699
+
595
700
  /* ─── Cell header (day label top-right) ──────────── */
596
701
  .wg-cell-hd {
597
702
  display: flex;
@@ -15,17 +15,11 @@
15
15
  -->
16
16
  <script lang="ts">
17
17
  import Calendar from '../calendar/Calendar.svelte';
18
- import type { CalendarView } from '../calendar/Calendar.svelte';
19
- import { Planner } from '../views/planner/index.js';
20
- import { Agenda } from '../views/agenda/index.js';
21
18
  import { createRestAdapter } from '../adapters/rest.js';
22
19
  import { createMemoryAdapter } from '../adapters/memory.js';
23
20
  import { presets } from '../theme/presets.js';
24
21
  import type { PresetName } from '../theme/presets.js';
25
22
  import type { TimelineEvent } from '../core/types.js';
26
- import { getLabels } from '../core/locale.js';
27
-
28
- const L = $derived(getLabels());
29
23
 
30
24
  interface Props {
31
25
  /** REST API base URL — if provided, fetches events from this endpoint */
@@ -64,7 +58,7 @@
64
58
  const heightPx = $derived(parseInt(height, 10) || 600);
65
59
  const isMondayStart = $derived(mondaystart !== 'false');
66
60
  const themeStyle = $derived(
67
- (presets as Record<string, string>)[theme] ?? presets.neutral
61
+ (presets as Record<string, string>)[theme] || presets.neutral
68
62
  );
69
63
  const dirValue = $derived(
70
64
  (dir === 'rtl' || dir === 'ltr' || dir === 'auto') ? dir as 'ltr' | 'rtl' | 'auto' : undefined
@@ -135,20 +129,11 @@
135
129
  }
136
130
  return createMemoryAdapter(parseEvents(events));
137
131
  });
138
-
139
- // ── Default views ──
140
- const defaultViews: CalendarView[] = $derived([
141
- { id: 'day-planner', label: L.planner, granularity: 'day', component: Planner, props: { mode: 'day' } },
142
- { id: 'week-planner', label: L.planner, granularity: 'week', component: Planner, props: { mode: 'week' } },
143
- { id: 'day-agenda', label: L.agenda, granularity: 'day', component: Agenda, props: { mode: 'day' } },
144
- { id: 'week-agenda', label: L.agenda, granularity: 'week', component: Agenda, props: { mode: 'week' } },
145
- ]);
146
132
  </script>
147
133
 
148
134
  <Calendar
149
135
  {adapter}
150
- views={defaultViews}
151
- defaultView={view}
136
+ {view}
152
137
  theme={themeStyle}
153
138
  height={heightPx}
154
139
  mondayStart={isMondayStart}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-calendar",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "A themeable Svelte 5 calendar with Day and Week views — Planner and Agenda.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
44
44
  "@types/node": "^22.0.0",
45
45
  "@vercel/analytics": "^1.6.1",
46
+ "jsdom": "^28.1.0",
46
47
  "marked": "^17.0.3",
47
48
  "svelte": "^5.51.0",
48
49
  "svelte-check": "^4.3.6",