@nomideusz/svelte-calendar 0.14.1 → 0.15.1

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.
@@ -31,445 +31,523 @@ import Agenda from "../views/agenda/Agenda.svelte";
31
31
  import Mobile from "../views/mobile/Mobile.svelte";
32
32
  import MonthGrid from "../views/month/MonthGrid.svelte";
33
33
  import { wrapAdapterWithTimezone, toZonedTime, fromZonedTime } from "../core/timezone.js";
34
+ /** Breakpoint (px) at which auto-mobile activates */
34
35
  const MOBILE_BREAKPOINT = 768;
36
+ // ── Built-in views (used when no custom views are provided) ──
35
37
  const DEFAULT_VIEWS = [
36
- { id: "day-planner", label: "Planner", mode: "day", component: Planner },
37
- { id: "week-planner", label: "Planner", mode: "week", component: Planner },
38
- { id: "day-agenda", label: "Agenda", mode: "day", component: Agenda },
39
- { id: "week-agenda", label: "Agenda", mode: "week", component: Agenda },
40
- { id: "day-mobile", label: "Mobile", mode: "day", component: Mobile },
41
- { id: "week-mobile", label: "Mobile", mode: "week", component: Mobile },
42
- { id: "month-grid", label: "Month", mode: "month", component: MonthGrid }
38
+ {
39
+ id: "day-planner",
40
+ label: "Planner",
41
+ mode: "day",
42
+ component: Planner
43
+ },
44
+ {
45
+ id: "week-planner",
46
+ label: "Planner",
47
+ mode: "week",
48
+ component: Planner
49
+ },
50
+ {
51
+ id: "day-agenda",
52
+ label: "Agenda",
53
+ mode: "day",
54
+ component: Agenda
55
+ },
56
+ {
57
+ id: "week-agenda",
58
+ label: "Agenda",
59
+ mode: "week",
60
+ component: Agenda
61
+ },
62
+ {
63
+ id: "day-mobile",
64
+ label: "Mobile",
65
+ mode: "day",
66
+ component: Mobile
67
+ },
68
+ {
69
+ id: "week-mobile",
70
+ label: "Mobile",
71
+ mode: "week",
72
+ component: Mobile
73
+ },
74
+ {
75
+ id: "month-grid",
76
+ label: "Month",
77
+ mode: "month",
78
+ component: MonthGrid
79
+ }
43
80
  ];
44
- let {
45
- adapter,
46
- views = DEFAULT_VIEWS,
47
- view: activeViewId,
48
- theme = auto,
49
- autoTheme,
50
- mondayStart = true,
51
- height: heightProp = 600,
52
- borderRadius = 12,
53
- dir,
54
- locale,
55
- labels: labelsProp,
56
- readOnly = false,
57
- visibleHours,
58
- initialDate,
59
- snapInterval = 15,
60
- minColumnWidth = 110,
61
- showModePills = true,
62
- showNavigation = true,
63
- equalDays = false,
64
- showDates = true,
65
- hideDays,
66
- currentDate,
67
- blockedSlots,
68
- days,
69
- minDuration,
70
- maxDuration,
71
- disabledDates,
72
- compact = false,
73
- columns = false,
74
- mobile: mobileProp = "auto",
75
- event: eventSnippet,
76
- empty: emptySnippet,
77
- dayHeader: dayHeaderSnippet,
78
- header: headerSnippet,
79
- navigation: navigationSnippet,
80
- oneventclick,
81
- oneventcreate,
82
- oneventmove,
83
- onviewchange,
84
- ondatechange,
85
- oneventhover,
86
- ondayclick,
87
- onerror,
88
- timezone
89
- } = $props();
81
+ let { adapter, views = DEFAULT_VIEWS, view: activeViewId, theme = auto, autoTheme, mondayStart = true, height: heightProp = 600, borderRadius = 12, dir, locale, labels: labelsProp, readOnly = false, visibleHours, initialDate, snapInterval = 15, minColumnWidth = 110, showModePills = true, showNavigation = true, equalDays = false, showDates = true, hideDays, currentDate, blockedSlots, days, minDuration, maxDuration, disabledDates, compact = false, columns = false, mobile: mobileProp = "auto", event: eventSnippet, empty: emptySnippet, dayHeader: dayHeaderSnippet, header: headerSnippet, navigation: navigationSnippet, oneventclick, oneventcreate, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
82
+ // In readOnly mode, suppress mutation callbacks. With a timezone, the
83
+ // drag plane is zoned wall-clock — hosts always receive real instants.
90
84
  const unzone = (d) => timezone ? fromZonedTime(d, timezone) : d;
91
- const effectiveCreate = $derived(
92
- readOnly || !oneventcreate ? void 0 : (range) => oneventcreate({ start: unzone(range.start), end: unzone(range.end) })
93
- );
94
- const effectiveMove = $derived(
95
- readOnly || !oneventmove ? void 0 : (ev, start, end) => oneventmove(ev, unzone(start), unzone(end))
96
- );
97
- function handleEventClick(ev) {
98
- selection.select(ev.id);
99
- oneventclick?.(ev);
85
+ const effectiveCreate = $derived(readOnly || !oneventcreate ? undefined : (range) => oneventcreate({
86
+ start: unzone(range.start),
87
+ end: unzone(range.end)
88
+ }));
89
+ const effectiveMove = $derived(readOnly || !oneventmove ? undefined : (ev, start, end) => oneventmove(ev, unzone(start), unzone(end)));
90
+ // Clicking an event selects it (highlight via selectedEventId) and then
91
+ // notifies the host — selection used to be created but never driven.
92
+ function handleEventClick(ev, anchor) {
93
+ selection.select(ev.id);
94
+ oneventclick?.(ev, anchor);
100
95
  }
101
- let containerWidth = $state(
102
- typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? window.innerWidth : 0
103
- );
96
+ // ── Mobile detection (container-based, not viewport) ──
97
+ // Seeded from viewport width so the first client paint doesn't flash the
98
+ // desktop chrome on phones; the ResizeObserver measurement takes over on mount.
99
+ let containerWidth = $state(typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? window.innerWidth : 0);
104
100
  const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
105
- const useMobile = $derived(
106
- mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp)
107
- );
101
+ const useMobile = $derived(mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp));
102
+ // Below this container width the mobile header can't fit pills + nav + a
103
+ // readable date label on one row, so the label moves to its own row.
108
104
  const HEADER_STACK_BREAKPOINT = 520;
109
- const stackHeader = $derived(
110
- useMobile && containerWidth > 0 && containerWidth < HEADER_STACK_BREAKPOINT
111
- );
105
+ const stackHeader = $derived(useMobile && containerWidth > 0 && containerWidth < HEADER_STACK_BREAKPOINT);
106
+ // ── Smart auto-theme ──
107
+ // When theme is empty (auto preset) and autoTheme is not false,
108
+ // probe the host page on mount and reactively watch for host theme changes.
112
109
  let calEl = $state();
113
110
  let probedTheme = $state("");
111
+ // Only delay rendering when auto-probe is actually needed
114
112
  const needsProbe = $derived(theme === auto && autoTheme !== false);
115
113
  onMount(() => {
116
- if (!calEl) return;
117
- containerWidth = calEl.clientWidth;
118
- const ro = new ResizeObserver((entries) => {
119
- containerWidth = Math.round(entries[0].contentRect.width);
120
- });
121
- ro.observe(calEl);
122
- if (!needsProbe) return () => ro.disconnect();
123
- const opts = typeof autoTheme === "object" ? autoTheme : {};
124
- const stopTheme = observeHostTheme(calEl, (vars) => {
125
- probedTheme = vars;
126
- }, opts);
127
- return () => {
128
- ro.disconnect();
129
- stopTheme?.();
130
- };
114
+ if (!calEl) return;
115
+ // Measure container width for mobile detection
116
+ containerWidth = calEl.clientWidth;
117
+ const ro = new ResizeObserver((entries) => {
118
+ containerWidth = Math.round(entries[0].contentRect.width);
119
+ });
120
+ ro.observe(calEl);
121
+ // Only probe theme when using the auto preset
122
+ if (!needsProbe) return () => ro.disconnect();
123
+ const opts = typeof autoTheme === "object" ? autoTheme : {};
124
+ const stopTheme = observeHostTheme(calEl, (vars) => {
125
+ probedTheme = vars;
126
+ }, opts);
127
+ return () => {
128
+ ro.disconnect();
129
+ stopTheme?.();
130
+ };
131
131
  });
132
+ /** Effective theme: user-provided takes priority, otherwise probed auto. */
132
133
  const effectiveTheme = $derived(theme === auto && autoTheme !== false ? probedTheme : theme);
133
- const effectiveAdapter = $derived(
134
- timezone ? wrapAdapterWithTimezone(adapter, timezone) : adapter
135
- );
134
+ // ── Create reactive state ──
135
+ const effectiveAdapter = $derived(timezone ? wrapAdapterWithTimezone(adapter, timezone) : adapter);
136
+ // The store is created ONCE and reads the adapter through a getter. A
137
+ // `$derived` store would be rebuilt — empty — whenever the host handed over a
138
+ // new adapter identity (a common "force a refetch" idiom), blanking the grid
139
+ // until the refetch landed. The load effect below re-runs on adapter change
140
+ // because store.load() reads effectiveAdapter inside it.
136
141
  const store = createEventStore(() => effectiveAdapter);
137
142
  const viewState = createViewState(untrack(() => ({
138
- view: activeViewId ?? views[0]?.id,
139
- mondayStart,
140
- // Focus lives on the zoned plane too — day boundaries follow the zone.
141
- initialDate: initialDate && timezone ? toZonedTime(initialDate, timezone) : initialDate,
142
- dayCount: days,
143
- timezone,
144
- modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode
143
+ view: activeViewId ?? views[0]?.id,
144
+ mondayStart,
145
+ // Focus lives on the zoned plane too — day boundaries follow the zone.
146
+ initialDate: initialDate && timezone ? toZonedTime(initialDate, timezone) : initialDate,
147
+ dayCount: days,
148
+ timezone,
149
+ modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode
145
150
  })));
146
151
  const selection = createSelection();
147
152
  const drag = createDragState();
153
+ // ── Drag commit handler ──
154
+ // Views call this on pointer-up to process drag results.
148
155
  async function commitDrag() {
149
- if (readOnly) {
150
- drag.cancel();
151
- return;
152
- }
153
- const mode = drag.mode;
154
- const payload = drag.commit();
155
- if (!payload) return;
156
- let { start, end } = payload;
157
- if (mode === "create" || mode === "resize-start" || mode === "resize-end") {
158
- if (end.getTime() <= start.getTime()) {
159
- const floorMs = Math.max(1, snapInterval) * 6e4;
160
- if (mode === "resize-start") start = new Date(end.getTime() - floorMs);
161
- else end = new Date(start.getTime() + floorMs);
162
- }
163
- const durationMs = end.getTime() - start.getTime();
164
- const durationMin = durationMs / 6e4;
165
- if (minDuration && durationMin < minDuration) {
166
- if (mode === "resize-start") {
167
- start = new Date(end.getTime() - minDuration * 6e4);
168
- } else {
169
- end = new Date(start.getTime() + minDuration * 6e4);
170
- }
171
- }
172
- if (maxDuration && durationMin > maxDuration) {
173
- if (mode === "resize-start") {
174
- start = new Date(end.getTime() - maxDuration * 6e4);
175
- } else {
176
- end = new Date(start.getTime() + maxDuration * 6e4);
177
- }
178
- }
179
- }
180
- if (disabledDates?.length) {
181
- const startDay = new Date(start);
182
- startDay.setHours(0, 0, 0, 0);
183
- const endDay = new Date(end.getTime() - 1);
184
- endDay.setHours(0, 0, 0, 0);
185
- for (const dd of disabledDates) {
186
- const dt = new Date(dd);
187
- dt.setHours(0, 0, 0, 0);
188
- const ts = dt.getTime();
189
- if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
190
- }
191
- }
192
- if (blockedSlots?.length) {
193
- const startH = start.getHours() + start.getMinutes() / 60;
194
- const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
195
- const jsDay = start.getDay();
196
- const isoDay = jsDay === 0 ? 7 : jsDay;
197
- for (const slot of blockedSlots) {
198
- if (slot.day && slot.day !== isoDay) continue;
199
- if (startH < slot.end && endH > slot.start) return;
200
- }
201
- }
202
- if ((mode === "move" || mode === "resize-start" || mode === "resize-end") && payload.eventId) {
203
- try {
204
- await store.move(payload.eventId, start, end);
205
- const ev = store.byId(payload.eventId);
206
- if (ev) effectiveMove?.(ev, start, end);
207
- } catch (e) {
208
- const msg = e instanceof Error ? e.message : "";
209
- if (msg.includes("read-only")) {
210
- const ev = store.byId(payload.eventId);
211
- if (ev) effectiveMove?.(ev, start, end);
212
- } else if (!msg.includes("not found")) {
213
- if (onerror) onerror(e instanceof Error ? e : new Error(String(e)));
214
- else console.warn("[calendar] drag commit failed:", e);
215
- }
216
- }
217
- } else if (mode === "create") {
218
- effectiveCreate?.({ start, end });
219
- }
156
+ if (readOnly) {
157
+ drag.cancel();
158
+ return;
159
+ }
160
+ const mode = drag.mode;
161
+ const payload = drag.commit();
162
+ if (!payload) return;
163
+ let { start, end } = payload;
164
+ // Enforce min/max duration for create and resize
165
+ if (mode === "create" || mode === "resize-start" || mode === "resize-end") {
166
+ // Defensive floor: never accept a zero/negative duration, even when
167
+ // minDuration is unset (views clamp, but the engine must hold alone).
168
+ if (end.getTime() <= start.getTime()) {
169
+ const floorMs = Math.max(1, snapInterval) * 6e4;
170
+ if (mode === "resize-start") start = new Date(end.getTime() - floorMs);
171
+ else end = new Date(start.getTime() + floorMs);
172
+ }
173
+ const durationMs = end.getTime() - start.getTime();
174
+ const durationMin = durationMs / 6e4;
175
+ if (minDuration && durationMin < minDuration) {
176
+ if (mode === "resize-start") {
177
+ start = new Date(end.getTime() - minDuration * 6e4);
178
+ } else {
179
+ end = new Date(start.getTime() + minDuration * 6e4);
180
+ }
181
+ }
182
+ if (maxDuration && durationMin > maxDuration) {
183
+ if (mode === "resize-start") {
184
+ start = new Date(end.getTime() - maxDuration * 6e4);
185
+ } else {
186
+ end = new Date(start.getTime() + maxDuration * 6e4);
187
+ }
188
+ }
189
+ }
190
+ // Reject if target lands on a disabled date
191
+ if (disabledDates?.length) {
192
+ const startDay = new Date(start);
193
+ startDay.setHours(0, 0, 0, 0);
194
+ const endDay = new Date(end.getTime() - 1);
195
+ endDay.setHours(0, 0, 0, 0);
196
+ for (const dd of disabledDates) {
197
+ const dt = new Date(dd);
198
+ dt.setHours(0, 0, 0, 0);
199
+ const ts = dt.getTime();
200
+ if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
201
+ }
202
+ }
203
+ // Reject if target overlaps a blocked slot
204
+ if (blockedSlots?.length) {
205
+ const startH = start.getHours() + start.getMinutes() / 60;
206
+ const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
207
+ const jsDay = start.getDay();
208
+ const isoDay = jsDay === 0 ? 7 : jsDay;
209
+ for (const slot of blockedSlots) {
210
+ if (slot.day && slot.day !== isoDay) continue;
211
+ if (startH < slot.end && endH > slot.start) return;
212
+ }
213
+ }
214
+ if ((mode === "move" || mode === "resize-start" || mode === "resize-end") && payload.eventId) {
215
+ try {
216
+ await store.move(payload.eventId, start, end);
217
+ const ev = store.byId(payload.eventId);
218
+ if (ev) effectiveMove?.(ev, start, end);
219
+ } catch (e) {
220
+ const msg = e instanceof Error ? e.message : "";
221
+ // Read-only adapter: the store can't persist, but the HOST still
222
+ // gets the callback — it owns persistence (e.g. scheduler RPC) and
223
+ // refetches. Missing event stays silent; real failures surface.
224
+ if (msg.includes("read-only")) {
225
+ const ev = store.byId(payload.eventId);
226
+ if (ev) effectiveMove?.(ev, start, end);
227
+ } else if (!msg.includes("not found")) {
228
+ if (onerror) onerror(e instanceof Error ? e : new Error(String(e)));
229
+ else console.warn("[calendar] drag commit failed:", e);
230
+ }
231
+ }
232
+ } else if (mode === "create") {
233
+ effectiveCreate?.({
234
+ start,
235
+ end
236
+ });
237
+ }
220
238
  }
239
+ // ── Load range signal ──
240
+ // Views can write a wider range here to override the default viewState.range.
241
+ // This lets infinite-scroll views (PlannerWeek) declare their
242
+ // buffer needs without directly calling store.load().
221
243
  let viewLoadRange = $state(null);
222
- const mergedLabels = $derived(
223
- labelsProp ? { ...getLabels(), ...labelsProp } : getLabels()
224
- );
244
+ // Per-instance labels: prop overrides merged over the global set, memoized so
245
+ // views reading ctx.labels per-render don't allocate a fresh object each access.
246
+ //
247
+ // MUST stay above setContext: the context exposes this through a getter, and
248
+ // a view reading ctx.labels while the binding is still uninitialised got the
249
+ // English globals instead — silently, because the fallback in
250
+ // useCalendarContext is `?? getLabels()`. Every localized calendar rendered
251
+ // its empty states in English.
252
+ const mergedLabels = $derived(labelsProp ? {
253
+ ...getLabels(),
254
+ ...labelsProp
255
+ } : getLabels());
225
256
  const L = $derived(mergedLabels);
257
+ // ── Single context object ──
258
+ // All view state is exposed through one context key with reactive getters.
259
+ // Views read this via useCalendarContext() from views/shared/context.svelte.ts.
226
260
  setContext("calendar", {
227
- // Engine objects (hold $state internally)
228
- get store() {
229
- return store;
230
- },
231
- viewState,
232
- selection,
233
- drag,
234
- commitDrag,
235
- // Callbacks
236
- get oneventclick() {
237
- return handleEventClick;
238
- },
239
- get oneventcreate() {
240
- return effectiveCreate;
241
- },
242
- get oneventmove() {
243
- return effectiveMove;
244
- },
245
- get oneventhover() {
246
- return oneventhover;
247
- },
248
- get ondayclick() {
249
- return ondayclick ?? defaultDayClick;
250
- },
251
- get timezone() {
252
- return timezone;
253
- },
254
- // Config (reactive via getters)
255
- get readOnly() {
256
- return readOnly;
257
- },
258
- get visibleHours() {
259
- return visibleHours;
260
- },
261
- get snapInterval() {
262
- return snapInterval;
263
- },
264
- get minColumnWidth() {
265
- return minColumnWidth;
266
- },
267
- get eventSnippet() {
268
- return eventSnippet;
269
- },
270
- get emptySnippet() {
271
- return emptySnippet;
272
- },
273
- get equalDays() {
274
- return equalDays;
275
- },
276
- get showDates() {
277
- return showDates;
278
- },
279
- get hideDays() {
280
- return hideDays;
281
- },
282
- get blockedSlots() {
283
- return blockedSlots;
284
- },
285
- get dayHeaderSnippet() {
286
- return dayHeaderSnippet;
287
- },
288
- get minDuration() {
289
- return minDuration;
290
- },
291
- get maxDuration() {
292
- return maxDuration;
293
- },
294
- get disabledDates() {
295
- return disabledDates;
296
- },
297
- get mobile() {
298
- return useMobile;
299
- },
300
- get autoHeight() {
301
- return heightProp === "auto";
302
- },
303
- get compact() {
304
- return compact;
305
- },
306
- get columns() {
307
- return columns;
308
- },
309
- get labels() {
310
- return mergedLabels;
311
- },
312
- // Load range (read/write)
313
- get loadRange() {
314
- return viewLoadRange;
315
- },
316
- setLoadRange(range) {
317
- viewLoadRange = range;
318
- }
261
+ // Engine objects (hold $state internally)
262
+ get store() {
263
+ return store;
264
+ },
265
+ viewState,
266
+ selection,
267
+ drag,
268
+ commitDrag,
269
+ // Callbacks
270
+ get oneventclick() {
271
+ return handleEventClick;
272
+ },
273
+ get oneventcreate() {
274
+ return effectiveCreate;
275
+ },
276
+ get oneventmove() {
277
+ return effectiveMove;
278
+ },
279
+ get oneventhover() {
280
+ return oneventhover;
281
+ },
282
+ get ondayclick() {
283
+ return ondayclick ?? defaultDayClick;
284
+ },
285
+ get timezone() {
286
+ return timezone;
287
+ },
288
+ // Config (reactive via getters)
289
+ get readOnly() {
290
+ return readOnly;
291
+ },
292
+ get visibleHours() {
293
+ return visibleHours;
294
+ },
295
+ get snapInterval() {
296
+ return snapInterval;
297
+ },
298
+ get minColumnWidth() {
299
+ return minColumnWidth;
300
+ },
301
+ get eventSnippet() {
302
+ return eventSnippet;
303
+ },
304
+ get emptySnippet() {
305
+ return emptySnippet;
306
+ },
307
+ get equalDays() {
308
+ return equalDays;
309
+ },
310
+ get showDates() {
311
+ return showDates;
312
+ },
313
+ get hideDays() {
314
+ return hideDays;
315
+ },
316
+ get blockedSlots() {
317
+ return blockedSlots;
318
+ },
319
+ get dayHeaderSnippet() {
320
+ return dayHeaderSnippet;
321
+ },
322
+ get minDuration() {
323
+ return minDuration;
324
+ },
325
+ get maxDuration() {
326
+ return maxDuration;
327
+ },
328
+ get disabledDates() {
329
+ return disabledDates;
330
+ },
331
+ get mobile() {
332
+ return useMobile;
333
+ },
334
+ get autoHeight() {
335
+ return heightProp === "auto";
336
+ },
337
+ get compact() {
338
+ return compact;
339
+ },
340
+ get columns() {
341
+ return columns;
342
+ },
343
+ get labels() {
344
+ return mergedLabels;
345
+ },
346
+ // Load range (read/write)
347
+ get loadRange() {
348
+ return viewLoadRange;
349
+ },
350
+ setLoadRange(range) {
351
+ viewLoadRange = range;
352
+ }
319
353
  });
354
+ // ── Load events when effective range changes ──
320
355
  $effect(() => {
321
- const range = viewLoadRange ?? viewState.range;
322
- store.load({ start: range.start, end: range.end });
356
+ const range = viewLoadRange ?? viewState.range;
357
+ store.load({
358
+ start: range.start,
359
+ end: range.end
360
+ });
323
361
  });
324
- untrack(() => store.load({ start: viewState.range.start, end: viewState.range.end }));
362
+ // Eager initial load $effect runs after paint, but for sync adapters
363
+ // (memory, recurring) this resolves in the same microtask.
364
+ untrack(() => store.load({
365
+ start: viewState.range.start,
366
+ end: viewState.range.end
367
+ }));
368
+ // Keep active view in sync when external view prop changes after mount.
325
369
  $effect(() => {
326
- if (activeViewId) viewState.setView(activeViewId);
370
+ if (activeViewId) viewState.setView(activeViewId);
327
371
  });
372
+ // Sync controlled currentDate prop → viewState
328
373
  $effect(() => {
329
- if (currentDate) viewState.setFocusDate(currentDate);
374
+ if (currentDate) viewState.setFocusDate(currentDate);
330
375
  });
376
+ // Sync days prop → viewState.dayCount
331
377
  $effect(() => {
332
- if (days !== void 0 && viewState.dayCount !== days) viewState.setDayCount(days);
378
+ if (days !== undefined && viewState.dayCount !== days) viewState.setDayCount(days);
333
379
  });
380
+ // Notify host when focusDate changes
334
381
  $effect(() => {
335
- const d = viewState.focusDate;
336
- ondatechange?.(d);
382
+ const d = viewState.focusDate;
383
+ ondatechange?.(d);
337
384
  });
385
+ // Keep view state's week-start rule in sync with incoming prop changes.
338
386
  $effect(() => {
339
- if (viewState.mondayStart !== mondayStart) {
340
- viewState.setMondayStart(mondayStart);
341
- }
387
+ if (viewState.mondayStart !== mondayStart) {
388
+ viewState.setMondayStart(mondayStart);
389
+ }
342
390
  });
391
+ // Notify host when active view changes (e.g. via mode toggles).
343
392
  $effect(() => {
344
- onviewchange?.(viewState.view);
393
+ onviewchange?.(viewState.view);
345
394
  });
395
+ // Surface adapter failures — store.error was previously write-only.
346
396
  $effect(() => {
347
- if (store.error && onerror) onerror(new Error(store.error));
397
+ if (store.error && onerror) onerror(new Error(store.error));
348
398
  });
399
+ // ── Resolve active view ──
400
+ // When mobile is active, Planner views get remapped to Mobile variants.
401
+ // Agenda views stay as Agenda — they're already list-based and will adapt
402
+ // via the 'calendar:mobile' context flag.
349
403
  const resolvedView = $derived.by(() => {
350
- const requested = views.find((v) => v.id === viewState.view) ?? views[0];
351
- if (!useMobile || !requested) return requested;
352
- if (requested.id.endsWith("-mobile")) return requested;
353
- if (requested.label === "Agenda") return requested;
354
- const mobileVariant = views.find(
355
- (v) => v.id === `${requested.mode}-mobile`
356
- );
357
- return mobileVariant ?? requested;
404
+ const requested = views.find((v) => v.id === viewState.view) ?? views[0];
405
+ if (!useMobile || !requested) return requested;
406
+ // Already a mobile view? Keep it.
407
+ if (requested.id.endsWith("-mobile")) return requested;
408
+ // Agenda views: keep as-is (they adapt internally via mobile context).
409
+ if (requested.label === "Agenda") return requested;
410
+ // Planner / other views: remap to mobile variant with the same mode.
411
+ const mobileVariant = views.find((v) => v.id === `${requested.mode}-mobile`);
412
+ return mobileVariant ?? requested;
358
413
  });
414
+ // Backward-compat alias used in the template.
359
415
  const activeView = $derived(resolvedView);
416
+ // Non-mobile views for mode pills (exclude mobile entries).
360
417
  const desktopViews = $derived(views.filter((v) => !v.id.endsWith("-mobile")));
418
+ // ── Date label (always visible, centered over views) ──
361
419
  const dateLabel = $derived.by(() => {
362
- if (!showDates) {
363
- return "";
364
- }
365
- if (viewState.mode === "day") {
366
- return viewState.focusDate.toLocaleDateString(locale, {
367
- weekday: "long",
368
- month: "short",
369
- day: "numeric"
370
- });
371
- }
372
- if (viewState.mode === "week") {
373
- return fmtWeekRange(
374
- viewState.range.start.getTime(),
375
- locale,
376
- viewState.range.end.getTime() - 1
377
- );
378
- }
379
- return viewState.focusDate.toLocaleDateString(locale, {
380
- month: "long",
381
- year: "numeric"
382
- });
420
+ if (!showDates) {
421
+ return "";
422
+ }
423
+ if (viewState.mode === "day") {
424
+ return viewState.focusDate.toLocaleDateString(locale, {
425
+ weekday: "long",
426
+ month: "short",
427
+ day: "numeric"
428
+ });
429
+ }
430
+ if (viewState.mode === "week") {
431
+ // The actual visible span — respects custom day counts (3/5-day views).
432
+ return fmtWeekRange(viewState.range.start.getTime(), locale, viewState.range.end.getTime() - 1);
433
+ }
434
+ return viewState.focusDate.toLocaleDateString(locale, {
435
+ month: "long",
436
+ year: "numeric"
437
+ });
383
438
  });
439
+ // Which modes are available?
384
440
  const modes = $derived.by(() => {
385
- const g = new Set(desktopViews.map((v) => v.mode));
386
- return ["day", "week", "month"].filter((key) => g.has(key));
441
+ const g = new Set(desktopViews.map((v) => v.mode));
442
+ return [
443
+ "day",
444
+ "week",
445
+ "month"
446
+ ].filter((key) => g.has(key));
387
447
  });
388
- let lastViewLabel = $state(void 0);
448
+ // Remember the last non-month view label so Month → Week round-trips
449
+ // restore the user's chosen view type (Agenda used to downgrade to Planner).
450
+ let lastViewLabel = $state(undefined);
389
451
  $effect(() => {
390
- const current = views.find((v) => v.id === viewState.view);
391
- if (current && current.mode !== "month") lastViewLabel = current.label;
452
+ const current = views.find((v) => v.id === viewState.view);
453
+ if (current && current.mode !== "month") lastViewLabel = current.label;
392
454
  });
455
+ /** Switch to a different mode (day/week/month), preserving the current view label. */
393
456
  function switchMode(g) {
394
- const currentView = desktopViews.find((v) => v.id === viewState.view) ?? activeView;
395
- const preferredLabel = currentView?.mode === "month" ? lastViewLabel ?? currentView?.label : currentView?.label;
396
- const match = desktopViews.find((v) => v.mode === g && v.label === preferredLabel);
397
- const fallback = desktopViews.find((v) => v.mode === g);
398
- const target = match ?? fallback;
399
- if (target) viewState.setView(target.id);
457
+ const currentView = desktopViews.find((v) => v.id === viewState.view) ?? activeView;
458
+ const preferredLabel = currentView?.mode === "month" ? lastViewLabel ?? currentView?.label : currentView?.label;
459
+ const match = desktopViews.find((v) => v.mode === g && v.label === preferredLabel);
460
+ const fallback = desktopViews.find((v) => v.mode === g);
461
+ const target = match ?? fallback;
462
+ if (target) viewState.setView(target.id);
400
463
  }
464
+ // ── View-type switcher (Planner ↔ Agenda) ──
465
+ // Distinct labels registered for the current mode; when there's more than
466
+ // one, the header shows a second pill group to switch between them.
401
467
  const labelsForMode = $derived.by(() => {
402
- const seen = [];
403
- for (const v of desktopViews) {
404
- if (v.mode === viewState.mode && !seen.includes(v.label)) seen.push(v.label);
405
- }
406
- return seen;
468
+ const seen = [];
469
+ for (const v of desktopViews) {
470
+ if (v.mode === viewState.mode && !seen.includes(v.label)) seen.push(v.label);
471
+ }
472
+ return seen;
407
473
  });
408
474
  function switchLabel(label) {
409
- const target = desktopViews.find((v) => v.mode === viewState.mode && v.label === label);
410
- if (target) viewState.setView(target.id);
475
+ const target = desktopViews.find((v) => v.mode === viewState.mode && v.label === label);
476
+ if (target) viewState.setView(target.id);
411
477
  }
478
+ // Default month → day drill-down: when the host doesn't wire ondayclick,
479
+ // clicking a month cell (or its "+N more") focuses that date in a day view,
480
+ // so the month grid is usable out of the box.
412
481
  const defaultDayClick = $derived.by(() => {
413
- const target = desktopViews.find((v) => v.mode === "day" && v.label === lastViewLabel) ?? desktopViews.find((v) => v.mode === "day");
414
- if (!target) return void 0;
415
- return (date) => {
416
- viewState.setFocusDate(date);
417
- viewState.setView(target.id);
418
- };
482
+ const target = desktopViews.find((v) => v.mode === "day" && v.label === lastViewLabel) ?? desktopViews.find((v) => v.mode === "day");
483
+ if (!target) return undefined;
484
+ return (date) => {
485
+ viewState.setFocusDate(date);
486
+ viewState.setView(target.id);
487
+ };
419
488
  });
489
+ /** True when the current view range already includes today. */
420
490
  const viewIncludesToday = $derived.by(() => {
421
- const now = /* @__PURE__ */ new Date();
422
- if (viewState.mode === "month") {
423
- const f = viewState.focusDate;
424
- return f.getMonth() === now.getMonth() && f.getFullYear() === now.getFullYear();
425
- }
426
- const { start, end } = viewState.range;
427
- return now.getTime() >= start.getTime() && now.getTime() < end.getTime();
491
+ const now = new Date();
492
+ if (viewState.mode === "month") {
493
+ // The month grid's range is week-aligned and includes adjacent-month
494
+ // spill days compare against the focused month instead.
495
+ const f = viewState.focusDate;
496
+ return f.getMonth() === now.getMonth() && f.getFullYear() === now.getFullYear();
497
+ }
498
+ const { start, end } = viewState.range;
499
+ return now.getTime() >= start.getTime() && now.getTime() < end.getTime();
428
500
  });
501
+ /** Text direction: explicit prop wins, otherwise derived from the locale. */
429
502
  const resolvedDir = $derived.by(() => {
430
- if (dir) return dir;
431
- if (!locale) return void 0;
432
- try {
433
- const info = new Intl.Locale(locale);
434
- const direction = info.textInfo?.direction ?? info.getTextInfo?.().direction;
435
- return direction === "rtl" ? "rtl" : void 0;
436
- } catch {
437
- return void 0;
438
- }
503
+ if (dir) return dir;
504
+ if (!locale) return undefined;
505
+ try {
506
+ const info = new Intl.Locale(locale);
507
+ const direction = info.textInfo?.direction ?? info.getTextInfo?.().direction;
508
+ return direction === "rtl" ? "rtl" : undefined;
509
+ } catch {
510
+ return undefined;
511
+ }
439
512
  });
513
+ // ── Keyboard shortcuts (scoped to focus-within-calendar, not global) ──
514
+ // t → today, ←/→ → prev/next. Skips events already handled by views
515
+ // (e.g. month-grid arrow navigation) and anything typed into a field.
440
516
  function handleShortcuts(e) {
441
- if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
442
- const t = e.target;
443
- if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
444
- if (e.key === "t" || e.key === "T") {
445
- e.preventDefault();
446
- viewState.goToday();
447
- } else if (e.key === "ArrowLeft") {
448
- e.preventDefault();
449
- viewState.prev();
450
- } else if (e.key === "ArrowRight") {
451
- e.preventDefault();
452
- viewState.next();
453
- }
517
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
518
+ const t = e.target;
519
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
520
+ if (e.key === "t" || e.key === "T") {
521
+ e.preventDefault();
522
+ viewState.goToday();
523
+ } else if (e.key === "ArrowLeft") {
524
+ e.preventDefault();
525
+ viewState.prev();
526
+ } else if (e.key === "ArrowRight") {
527
+ e.preventDefault();
528
+ viewState.next();
529
+ }
454
530
  }
531
+ /** Header context for custom header snippet */
455
532
  const headerCtx = $derived({
456
- dateLabel,
457
- mode: viewState.mode,
458
- modes,
459
- switchMode,
460
- prev: () => viewState.prev(),
461
- next: () => viewState.next(),
462
- goToday: () => viewState.goToday(),
463
- isViewOnToday: viewIncludesToday,
464
- focusDate: viewState.focusDate
533
+ dateLabel,
534
+ mode: viewState.mode,
535
+ modes,
536
+ switchMode,
537
+ prev: () => viewState.prev(),
538
+ next: () => viewState.next(),
539
+ goToday: () => viewState.goToday(),
540
+ isViewOnToday: viewIncludesToday,
541
+ focusDate: viewState.focusDate
465
542
  });
543
+ /** Navigation context for custom navigation snippet */
466
544
  const navCtx = $derived({
467
- prev: () => viewState.prev(),
468
- next: () => viewState.next(),
469
- goToday: () => viewState.goToday(),
470
- isViewOnToday: viewIncludesToday,
471
- focusDate: viewState.focusDate,
472
- mode: viewState.mode
545
+ prev: () => viewState.prev(),
546
+ next: () => viewState.next(),
547
+ goToday: () => viewState.goToday(),
548
+ isViewOnToday: viewIncludesToday,
549
+ focusDate: viewState.focusDate,
550
+ mode: viewState.mode
473
551
  });
474
552
  </script>
475
553