@nomideusz/svelte-calendar 0.21.0 → 1.0.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.
Files changed (69) hide show
  1. package/README.md +502 -269
  2. package/dist/adapters/composite.d.ts +12 -1
  3. package/dist/adapters/composite.js +109 -47
  4. package/dist/adapters/errors.d.ts +22 -0
  5. package/dist/adapters/errors.js +32 -0
  6. package/dist/adapters/index.d.ts +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/adapters/jmap.js +26 -17
  9. package/dist/adapters/mapped.d.ts +2 -2
  10. package/dist/adapters/mapped.js +18 -9
  11. package/dist/adapters/memory.d.ts +1 -1
  12. package/dist/adapters/memory.js +5 -3
  13. package/dist/adapters/recurring.d.ts +1 -1
  14. package/dist/adapters/recurring.js +37 -19
  15. package/dist/adapters/rest.d.ts +2 -2
  16. package/dist/adapters/rest.js +9 -4
  17. package/dist/calendar/Calendar.svelte +122 -78
  18. package/dist/calendar/Calendar.svelte.d.ts +69 -14
  19. package/dist/calendar/index.d.ts +2 -2
  20. package/dist/calendar/index.js +1 -1
  21. package/dist/core/clock.svelte.js +3 -2
  22. package/dist/core/locale.d.ts +11 -1
  23. package/dist/core/locale.js +12 -12
  24. package/dist/core/time.d.ts +9 -0
  25. package/dist/core/time.js +8 -0
  26. package/dist/core/timezone.d.ts +7 -3
  27. package/dist/core/timezone.js +10 -4
  28. package/dist/engine/event-store.svelte.js +93 -56
  29. package/dist/engine/view-state.svelte.d.ts +5 -1
  30. package/dist/engine/view-state.svelte.js +5 -3
  31. package/dist/headless/create-agenda.svelte.d.ts +8 -1
  32. package/dist/headless/create-agenda.svelte.js +32 -14
  33. package/dist/headless/create-calendar.svelte.js +53 -17
  34. package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
  35. package/dist/headless/create-range-agenda.svelte.js +33 -12
  36. package/dist/headless/index.d.ts +1 -0
  37. package/dist/index.d.ts +10 -7
  38. package/dist/index.js +8 -5
  39. package/dist/primitives/DayHeader.svelte +3 -2
  40. package/dist/primitives/DayHeader.svelte.d.ts +1 -1
  41. package/dist/primitives/EmptySlot.svelte +5 -2
  42. package/dist/primitives/EventBlock.svelte +21 -14
  43. package/dist/primitives/FloatingPanel.svelte +110 -15
  44. package/dist/primitives/FloatingPanel.svelte.d.ts +7 -5
  45. package/dist/primitives/NowIndicator.svelte +11 -4
  46. package/dist/primitives/TimeGutter.svelte +10 -6
  47. package/dist/primitives/index.d.ts +1 -0
  48. package/dist/text-fit.d.ts +8 -2
  49. package/dist/views/agenda/AgendaDay.svelte +41 -33
  50. package/dist/views/agenda/AgendaWeek.svelte +71 -34
  51. package/dist/views/index.d.ts +2 -0
  52. package/dist/views/index.js +2 -0
  53. package/dist/views/mobile/MobileDay.svelte +98 -41
  54. package/dist/views/mobile/MobileWeek.svelte +29 -13
  55. package/dist/views/mobile/swipe.js +6 -1
  56. package/dist/views/month/MonthGrid.svelte +29 -17
  57. package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
  58. package/dist/views/planner/PlannerScroll.svelte +198 -61
  59. package/dist/views/planner/PlannerWeek.svelte +148 -48
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +29 -14
  62. package/widget/widget.js +7983 -7688
  63. package/dist/assets/favicon.svg +0 -9
  64. package/dist/widget/CalendarWidget.svelte +0 -114
  65. package/dist/widget/CalendarWidget.svelte.d.ts +0 -37
  66. package/dist/widget/index.d.ts +0 -1
  67. package/dist/widget/index.js +0 -1
  68. package/dist/widget/widget.d.ts +0 -10
  69. package/dist/widget/widget.js +0 -96
@@ -1,7 +1,12 @@
1
+ /** JSON carries dates as strings; the calendar needs Date objects. */
2
+ function reviveEvent(data) {
3
+ const ev = data;
4
+ return { ...ev, start: new Date(ev.start), end: new Date(ev.end) };
5
+ }
1
6
  export function createRestAdapter(options) {
2
7
  const { baseUrl, headers = {} } = options;
3
- const mapEvents = options.mapEvents ?? ((data) => data);
4
- const mapEvent = options.mapEvent ?? ((data) => data);
8
+ const mapEvents = options.mapEvents ?? ((data) => data.map(reviveEvent));
9
+ const mapEvent = options.mapEvent ?? reviveEvent;
5
10
  async function request(path, init) {
6
11
  const res = await fetch(`${baseUrl}${path}`, {
7
12
  ...init,
@@ -40,14 +45,14 @@ export function createRestAdapter(options) {
40
45
  return mapEvent(data);
41
46
  },
42
47
  async updateEvent(id, patch) {
43
- const data = await request(`/events/${id}`, {
48
+ const data = await request(`/events/${encodeURIComponent(id)}`, {
44
49
  method: 'PATCH',
45
50
  body: JSON.stringify(patch),
46
51
  });
47
52
  return mapEvent(data);
48
53
  },
49
54
  async deleteEvent(id) {
50
- await request(`/events/${id}`, { method: 'DELETE' });
55
+ await request(`/events/${encodeURIComponent(id)}`, { method: 'DELETE' });
51
56
  },
52
57
  };
53
58
  }
@@ -17,25 +17,19 @@
17
17
  oneventcreate={handleCreate}
18
18
  />
19
19
  -->
20
- <script lang="ts">import { setContext, untrack } from "svelte";
21
- import { createEventStore } from "../engine/event-store.svelte.js";
22
- import { createViewState } from "../engine/view-state.svelte.js";
23
- import { createSelection } from "../engine/selection.svelte.js";
24
- import { createDragState } from "../engine/drag.svelte.js";
25
- import { onMount } from "svelte";
26
- import { getLabels, fmtWeekRange } from "../core/locale.js";
27
- import { auto } from "../theme/presets.js";
28
- import { probeHostTheme, observeHostTheme } from "../theme/auto.js";
29
- import Planner from "../views/planner/Planner.svelte";
20
+ <script module lang="ts">import Planner from "../views/planner/Planner.svelte";
30
21
  import PlannerScroll from "../views/planner/PlannerScroll.svelte";
31
22
  import Agenda from "../views/agenda/Agenda.svelte";
32
23
  import Mobile from "../views/mobile/Mobile.svelte";
33
24
  import MonthGrid from "../views/month/MonthGrid.svelte";
34
- import { wrapAdapterWithTimezone, toZonedTime, fromZonedTime } from "../core/timezone.js";
35
- /** Breakpoint (px) at which auto-mobile activates */
36
- const MOBILE_BREAKPOINT = 768;
37
- // ── Built-in views (used when no custom views are provided) ──
38
- const DEFAULT_VIEWS = [
25
+ /**
26
+ * The built-in views — the registry a Calendar uses without a `views` prop.
27
+ * Spread it to add your own view and keep these:
28
+ * `views={[...defaultViews, { id: 'day-kanban', ... }]}`.
29
+ * On phones (`mobile: 'auto'`), every view except `*-agenda` and `*-mobile`
30
+ * swaps to the registered `{mode}-mobile` view, when there is one.
31
+ */
32
+ export const defaultViews = [
39
33
  {
40
34
  id: "day-planner",
41
35
  label: "Planner",
@@ -85,10 +79,29 @@ const DEFAULT_VIEWS = [
85
79
  component: MonthGrid
86
80
  }
87
81
  ];
88
- 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, onexternaldrop, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
82
+ </script>
83
+
84
+ <script lang="ts">import { setContext, untrack } from "svelte";
85
+ import { createEventStore } from "../engine/event-store.svelte.js";
86
+ import { createViewState } from "../engine/view-state.svelte.js";
87
+ import { createSelection } from "../engine/selection.svelte.js";
88
+ import { createDragState } from "../engine/drag.svelte.js";
89
+ import { onMount } from "svelte";
90
+ import { getLabels, getDefaultLocale, fmtWeekRange } from "../core/locale.js";
91
+ import { createClock } from "../core/clock.svelte.js";
92
+ import { auto } from "../theme/presets.js";
93
+ import { observeHostTheme } from "../theme/auto.js";
94
+ import { wrapAdapterWithTimezone, toZonedTime, fromZonedTime } from "../core/timezone.js";
95
+ import { isReadOnlyError, isNotFoundError } from "../adapters/errors.js";
96
+ /** Breakpoint (px) at which auto-mobile activates */
97
+ const MOBILE_BREAKPOINT = 768;
98
+ let { adapter, views = defaultViews, 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, onexternaldrop, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
89
99
  // In readOnly mode, suppress mutation callbacks. With a timezone, the
90
100
  // drag plane is zoned wall-clock — hosts always receive real instants.
91
101
  const unzone = (d) => timezone ? fromZonedTime(d, timezone) : d;
102
+ // Dates handed IN (initialDate, currentDate) are instants; the views work on
103
+ // the zoned wall-clock plane.
104
+ const zoneIn = (d) => d && timezone ? toZonedTime(d, timezone) : d;
92
105
  const effectiveCreate = $derived(readOnly || !oneventcreate ? undefined : (range) => oneventcreate({
93
106
  start: unzone(range.start),
94
107
  end: unzone(range.end)
@@ -105,9 +118,11 @@ function handleEventClick(ev, anchor) {
105
118
  oneventclick?.(ev, anchor);
106
119
  }
107
120
  // ── Mobile detection (container-based, not viewport) ──
108
- // Seeded from viewport width so the first client paint doesn't flash the
109
- // desktop chrome on phones; the ResizeObserver measurement takes over on mount.
110
- let containerWidth = $state(typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? window.innerWidth : 0);
121
+ // Starts unmeasured on server AND client: the first client render must be
122
+ // the markup the server sent, or hydration fails (a phone reload of an SSR
123
+ // page used to throw in the mobile branch). onMount measures before the
124
+ // next paint; a page without SSR renders the right layout at once.
125
+ let containerWidth = $state(0);
111
126
  const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
112
127
  const useMobile = $derived(mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp));
113
128
  // Below this container width the mobile header can't fit pills + nav + a
@@ -154,24 +169,15 @@ const viewState = createViewState(untrack(() => ({
154
169
  view: activeViewId ?? views[0]?.id,
155
170
  mondayStart,
156
171
  // Focus lives on the zoned plane too — day boundaries follow the zone.
157
- initialDate: initialDate && timezone ? toZonedTime(initialDate, timezone) : initialDate,
172
+ // A controlled currentDate wins, so the server renders the right period.
173
+ initialDate: zoneIn(currentDate ?? initialDate),
158
174
  dayCount: days,
159
175
  timezone,
160
176
  modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode
161
177
  })));
162
178
  const selection = createSelection();
163
179
  const drag = createDragState();
164
- // ── Drag commit handler ──
165
- // Views call this on pointer-up to process drag results.
166
- async function commitDrag() {
167
- if (readOnly) {
168
- drag.cancel();
169
- return;
170
- }
171
- const mode = drag.mode;
172
- const payload = drag.commit();
173
- if (!payload) return;
174
- let { start, end } = payload;
180
+ function validateRange(mode, start, end) {
175
181
  // Enforce min/max duration for create and resize
176
182
  if (mode === "create" || mode === "resize-start" || mode === "resize-end") {
177
183
  // Defensive floor: never accept a zero/negative duration, even when
@@ -181,24 +187,14 @@ async function commitDrag() {
181
187
  if (mode === "resize-start") start = new Date(end.getTime() - floorMs);
182
188
  else end = new Date(start.getTime() + floorMs);
183
189
  }
184
- const durationMs = end.getTime() - start.getTime();
185
- const durationMin = durationMs / 6e4;
186
- if (minDuration && durationMin < minDuration) {
187
- if (mode === "resize-start") {
188
- start = new Date(end.getTime() - minDuration * 6e4);
189
- } else {
190
- end = new Date(start.getTime() + minDuration * 6e4);
191
- }
192
- }
193
- if (maxDuration && durationMin > maxDuration) {
194
- if (mode === "resize-start") {
195
- start = new Date(end.getTime() - maxDuration * 6e4);
196
- } else {
197
- end = new Date(start.getTime() + maxDuration * 6e4);
198
- }
190
+ const durationMin = (end.getTime() - start.getTime()) / 6e4;
191
+ const clampTo = minDuration && durationMin < minDuration ? minDuration : maxDuration && durationMin > maxDuration ? maxDuration : null;
192
+ if (clampTo !== null) {
193
+ if (mode === "resize-start") start = new Date(end.getTime() - clampTo * 6e4);
194
+ else end = new Date(start.getTime() + clampTo * 6e4);
199
195
  }
200
196
  }
201
- // Reject if target lands on a disabled date
197
+ // Reject if the range touches a disabled date
202
198
  if (disabledDates?.length) {
203
199
  const startDay = new Date(start);
204
200
  startDay.setHours(0, 0, 0, 0);
@@ -208,10 +204,10 @@ async function commitDrag() {
208
204
  const dt = new Date(dd);
209
205
  dt.setHours(0, 0, 0, 0);
210
206
  const ts = dt.getTime();
211
- if (ts >= startDay.getTime() && ts <= endDay.getTime()) return;
207
+ if (ts >= startDay.getTime() && ts <= endDay.getTime()) return null;
212
208
  }
213
209
  }
214
- // Reject if target overlaps a blocked slot
210
+ // Reject if the range overlaps a blocked slot
215
211
  if (blockedSlots?.length) {
216
212
  const startH = start.getHours() + start.getMinutes() / 60;
217
213
  const endH = end.getHours() + end.getMinutes() / 60 + (end.getDate() !== start.getDate() ? 24 : 0);
@@ -219,25 +215,51 @@ async function commitDrag() {
219
215
  const isoDay = jsDay === 0 ? 7 : jsDay;
220
216
  for (const slot of blockedSlots) {
221
217
  if (slot.day && slot.day !== isoDay) continue;
222
- if (startH < slot.end && endH > slot.start) return;
218
+ if (startH < slot.end && endH > slot.start) return null;
223
219
  }
224
220
  }
221
+ return {
222
+ start,
223
+ end
224
+ };
225
+ }
226
+ // What views call for a click-to-create: the same checks a drag gets.
227
+ const checkedCreate = $derived.by(() => {
228
+ const create = effectiveCreate;
229
+ if (!create) return undefined;
230
+ return (range) => {
231
+ const ok = validateRange("create", range.start, range.end);
232
+ if (ok) create(ok);
233
+ };
234
+ });
235
+ // ── Drag commit handler ──
236
+ // Views call this on pointer-up to process drag results.
237
+ async function commitDrag() {
238
+ if (readOnly) {
239
+ drag.cancel();
240
+ return;
241
+ }
242
+ const mode = drag.mode;
243
+ const payload = drag.commit();
244
+ if (!payload || mode === "none") return;
245
+ const checked = validateRange(mode, payload.start, payload.end);
246
+ if (!checked) return;
247
+ const { start, end } = checked;
225
248
  if ((mode === "move" || mode === "resize-start" || mode === "resize-end") && payload.eventId) {
226
249
  try {
227
250
  await store.move(payload.eventId, start, end);
228
251
  const ev = store.byId(payload.eventId);
229
252
  if (ev) effectiveMove?.(ev, start, end);
230
253
  } catch (e) {
231
- const msg = e instanceof Error ? e.message : "";
232
254
  // Read-only adapter: the store can't persist, but the HOST still
233
255
  // gets the callback — it owns persistence (e.g. scheduler RPC) and
234
256
  // refetches. Missing event stays silent; real failures surface.
235
- if (msg.includes("read-only")) {
257
+ if (isReadOnlyError(e)) {
236
258
  const ev = store.byId(payload.eventId);
237
259
  if (ev) effectiveMove?.(ev, start, end);
238
- } else if (!msg.includes("not found")) {
239
- if (onerror) onerror(e instanceof Error ? e : new Error(String(e)));
240
- else console.warn("[calendar] drag commit failed:", e);
260
+ } else if (!isNotFoundError(e) && !onerror) {
261
+ // With onerror, the store.error effect below reports it — once.
262
+ console.warn("[calendar] drag commit failed:", e);
241
263
  }
242
264
  }
243
265
  } else if (mode === "create") {
@@ -282,7 +304,7 @@ setContext("calendar", {
282
304
  return handleEventClick;
283
305
  },
284
306
  get oneventcreate() {
285
- return effectiveCreate;
307
+ return checkedCreate;
286
308
  },
287
309
  get oneventmove() {
288
310
  return effectiveMove;
@@ -291,7 +313,7 @@ setContext("calendar", {
291
313
  return oneventhover;
292
314
  },
293
315
  get ondayclick() {
294
- return ondayclick ?? defaultDayClick;
316
+ return ondayclick ? (d) => ondayclick(unzone(d)) : defaultDayClick;
295
317
  },
296
318
  get timezone() {
297
319
  return timezone;
@@ -370,19 +392,27 @@ $effect(() => {
370
392
  end: range.end
371
393
  });
372
394
  });
373
- // Eager initial load — $effect runs after paint, but for sync adapters
374
- // (memory, recurring) this resolves in the same microtask.
375
- untrack(() => store.load({
376
- start: viewState.range.start,
377
- end: viewState.range.end
378
- }));
395
+ // Eager initial load for adapters that answer synchronously (memory,
396
+ // recurring, seeded): the server render and the first paint hold the
397
+ // events. An async adapter waits for the effect — by then the view has
398
+ // declared its wider load range, so it is fetched once, not twice.
399
+ untrack(() => {
400
+ if (effectiveAdapter.fetchEventsSync) store.load({
401
+ start: viewState.range.start,
402
+ end: viewState.range.end
403
+ });
404
+ });
379
405
  // Keep active view in sync when external view prop changes after mount.
380
406
  $effect(() => {
381
407
  if (activeViewId) viewState.setView(activeViewId);
382
408
  });
383
409
  // Sync controlled currentDate prop → viewState
384
410
  $effect(() => {
385
- if (currentDate) viewState.setFocusDate(currentDate);
411
+ const d = zoneIn(currentDate);
412
+ // Compared by time: the host often hands back what ondatechange gave it.
413
+ if (d && untrack(() => viewState.focusDate.getTime()) !== d.getTime()) {
414
+ untrack(() => viewState.setFocusDate(d));
415
+ }
386
416
  });
387
417
  // Sync days prop → viewState.dayCount
388
418
  $effect(() => {
@@ -390,8 +420,10 @@ $effect(() => {
390
420
  });
391
421
  // Notify host when focusDate changes
392
422
  $effect(() => {
393
- const d = viewState.focusDate;
394
- ondatechange?.(d);
423
+ // An instant, like currentDate takes — so a host that feeds it back
424
+ // (currentDate={d} ondatechange={(x) => d = x}) gets the same day back.
425
+ const d = unzone(viewState.focusDate);
426
+ untrack(() => ondatechange?.(d));
395
427
  });
396
428
  // Keep view state's week-start rule in sync with incoming prop changes.
397
429
  $effect(() => {
@@ -417,7 +449,7 @@ const resolvedView = $derived.by(() => {
417
449
  // Already a mobile view? Keep it.
418
450
  if (requested.id.endsWith("-mobile")) return requested;
419
451
  // Agenda views: keep as-is (they adapt internally via mobile context).
420
- if (requested.label === "Agenda") return requested;
452
+ if (requested.component === Agenda || requested.id.endsWith("-agenda")) return requested;
421
453
  // Planner / other views: remap to mobile variant with the same mode.
422
454
  const mobileVariant = views.find((v) => v.id === `${requested.mode}-mobile`);
423
455
  return mobileVariant ?? requested;
@@ -432,7 +464,7 @@ const dateLabel = $derived.by(() => {
432
464
  return "";
433
465
  }
434
466
  if (viewState.mode === "day") {
435
- return viewState.focusDate.toLocaleDateString(locale, {
467
+ return viewState.focusDate.toLocaleDateString(locale ?? getDefaultLocale(), {
436
468
  weekday: "long",
437
469
  month: "short",
438
470
  day: "numeric"
@@ -442,7 +474,7 @@ const dateLabel = $derived.by(() => {
442
474
  // The actual visible span — respects custom day counts (3/5-day views).
443
475
  return fmtWeekRange(viewState.range.start.getTime(), locale, viewState.range.end.getTime() - 1);
444
476
  }
445
- return viewState.focusDate.toLocaleDateString(locale, {
477
+ return viewState.focusDate.toLocaleDateString(locale ?? getDefaultLocale(), {
446
478
  month: "long",
447
479
  year: "numeric"
448
480
  });
@@ -482,6 +514,13 @@ const labelsForMode = $derived.by(() => {
482
514
  }
483
515
  return seen;
484
516
  });
517
+ /** Built-in view-type names are shown in the calendar's language. */
518
+ function viewLabel(label) {
519
+ if (label === "Planner") return L.planner;
520
+ if (label === "Agenda") return L.agenda;
521
+ if (label === "Scroll") return L.scroll;
522
+ return label;
523
+ }
485
524
  function switchLabel(label) {
486
525
  const target = desktopViews.find((v) => v.mode === viewState.mode && v.label === label);
487
526
  if (target) viewState.setView(target.id);
@@ -498,8 +537,11 @@ const defaultDayClick = $derived.by(() => {
498
537
  };
499
538
  });
500
539
  /** True when the current view range already includes today. */
540
+ // Ticks on the zoned plane, so "today" is today in `timezone` and the
541
+ // Today button re-enables when the day rolls over.
542
+ const clock = createClock(untrack(() => timezone));
501
543
  const viewIncludesToday = $derived.by(() => {
502
- const now = new Date();
544
+ const now = new Date(clock.tick);
503
545
  if (viewState.mode === "month") {
504
546
  // The month grid's range is week-aligned and includes adjacent-month
505
547
  // spill days — compare against the focused month instead.
@@ -528,15 +570,17 @@ function handleShortcuts(e) {
528
570
  if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
529
571
  const t = e.target;
530
572
  if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
573
+ // Arrows inside a radio group move between its options, not the period.
574
+ const inRadio = t?.getAttribute("role") === "radio";
575
+ // Arrows follow the reading direction: in RTL, ← is "later".
576
+ const rtl = !!calEl && getComputedStyle(calEl).direction === "rtl";
531
577
  if (e.key === "t" || e.key === "T") {
532
578
  e.preventDefault();
533
579
  viewState.goToday();
534
- } else if (e.key === "ArrowLeft") {
535
- e.preventDefault();
536
- viewState.prev();
537
- } else if (e.key === "ArrowRight") {
580
+ } else if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !inRadio) {
538
581
  e.preventDefault();
539
- viewState.next();
582
+ if (e.key === "ArrowRight" !== rtl) viewState.next();
583
+ else viewState.prev();
540
584
  }
541
585
  }
542
586
  /** Header context for custom header snippet */
@@ -674,7 +718,7 @@ const navCtx = $derived({
674
718
  aria-checked={activeView?.label === label}
675
719
  onclick={() => switchLabel(label)}
676
720
  >
677
- {label}
721
+ {viewLabel(label)}
678
722
  </button>
679
723
  {/each}
680
724
  </div>
@@ -711,7 +755,7 @@ const navCtx = $derived({
711
755
  {locale}
712
756
  focusDate={viewState.focusDate}
713
757
  oneventclick={handleEventClick}
714
- oneventcreate={effectiveCreate}
758
+ oneventcreate={checkedCreate}
715
759
  onexternaldrop={effectiveExternalDrop}
716
760
  readOnly={readOnly}
717
761
  visibleHours={visibleHours}
@@ -719,7 +763,7 @@ const navCtx = $derived({
719
763
  {...activeView.props ?? {}}
720
764
  />
721
765
  {:else}
722
- <div class="cal-empty">No views registered.</div>
766
+ <div class="cal-empty">{L.noViews}</div>
723
767
  {/if}
724
768
  </div>
725
769
 
@@ -1,25 +1,57 @@
1
- import { type Component, type Snippet } from 'svelte';
1
+ import type { Component, Snippet } from 'svelte';
2
2
  import type { CalendarAdapter } from '../adapters/types.js';
3
3
  import type { CalendarViewId } from '../engine/view-state.svelte.js';
4
4
  import type { TimelineEvent, BlockedSlot } from '../core/types.js';
5
- import { type CalendarLabels } from '../core/locale.js';
5
+ import type { CalendarLabels } from '../core/locale.js';
6
6
  import type { AutoThemeOptions } from '../theme/auto.js';
7
+ import type { HeaderContext, NavigationContext } from '../headless/types.js';
8
+ /**
9
+ * What the Calendar passes to the active view's component. Engines (store,
10
+ * view state, drag, labels…) are read from context — `useCalendarContext()`.
11
+ */
12
+ export interface CalendarViewProps {
13
+ events: TimelineEvent[];
14
+ /** The active `--dt-*` theme string */
15
+ style: string;
16
+ height: number | null;
17
+ mode: 'day' | 'week' | 'month';
18
+ mondayStart: boolean;
19
+ locale: string | undefined;
20
+ focusDate: Date;
21
+ oneventclick: (event: TimelineEvent, anchor?: DOMRect) => void;
22
+ /** Already gated by `readOnly` and validated; undefined when creating is off */
23
+ oneventcreate: ((range: {
24
+ start: Date;
25
+ end: Date;
26
+ }) => void) | undefined;
27
+ onexternaldrop: ((info: {
28
+ start: Date;
29
+ dataTransfer: DataTransfer;
30
+ }) => void) | undefined;
31
+ readOnly: boolean;
32
+ visibleHours: [number, number] | undefined;
33
+ selectedEventId: string | null;
34
+ }
7
35
  /** One view registration */
8
36
  export interface CalendarView {
9
37
  id: CalendarViewId;
38
+ /** The view-type name shown in the header pills when a mode has several views */
10
39
  label: string;
11
40
  /** day, week or month */
12
41
  mode: 'day' | 'week' | 'month';
13
- /** The Svelte component to render */
14
- component: Component<Record<string, unknown>>;
15
- /** Extra props to pass through (e.g. hourHeight, specialized settings) */
42
+ /**
43
+ * The Svelte component to render. It receives `CalendarViewProps` plus
44
+ * `props`; declare only the ones it uses.
45
+ */
46
+ component: Component<any>;
47
+ /** Extra props passed through to the component */
16
48
  props?: Record<string, unknown>;
17
49
  }
18
- interface Props {
50
+ export interface CalendarProps {
19
51
  /** Data adapter (required) */
20
52
  adapter: CalendarAdapter;
21
- /** Registered views */
22
- views?: CalendarView[];
53
+ /** Registered views (default: `defaultViews`) */
54
+ views?: readonly CalendarView[];
23
55
  /** Active view ID (defaults to first registered view) */
24
56
  view?: CalendarViewId;
25
57
  /** CSS theme string (--dt-* inline style) */
@@ -74,7 +106,10 @@ interface Props {
74
106
  showDates?: boolean;
75
107
  /** ISO weekdays to hide (1=Mon … 7=Sun). E.g. [6, 7] hides weekends. */
76
108
  hideDays?: number[];
77
- /** Controlled current date — drives which date the calendar focuses on. */
109
+ /**
110
+ * Controlled current date — drives which date the calendar focuses on.
111
+ * An instant, like `initialDate`: with `timezone` it is read in that zone.
112
+ */
78
113
  currentDate?: Date;
79
114
  /** Blocked/unavailable time slots — rendered as hatched regions, prevent event creation. */
80
115
  blockedSlots?: BlockedSlot[];
@@ -117,12 +152,12 @@ interface Props {
117
152
  * Replace the entire header chrome (date label + mode pills + nav arrows).
118
153
  * Receives context: { dateLabel, mode, modes, switchMode, prev, next, goToday, isViewOnToday, focusDate }.
119
154
  */
120
- header?: Snippet<[import('../headless/types.js').HeaderContext]>;
155
+ header?: Snippet<[HeaderContext]>;
121
156
  /**
122
157
  * Replace just the navigation controls (arrows + today button).
123
158
  * Receives context: { prev, next, goToday, isViewOnToday, focusDate, mode }.
124
159
  */
125
- navigation?: Snippet<[import('../headless/types.js').NavigationContext]>;
160
+ navigation?: Snippet<[NavigationContext]>;
126
161
  /** `anchor` is the clicked block's viewport rect where a view has one (planner) — for positioning a FloatingPanel. */
127
162
  oneventclick?: (event: TimelineEvent, anchor?: DOMRect) => void;
128
163
  oneventcreate?: (range: {
@@ -136,13 +171,25 @@ interface Props {
136
171
  start: Date;
137
172
  dataTransfer: DataTransfer;
138
173
  }) => void;
174
+ /**
175
+ * An event was dragged or resized. `newStart`/`newEnd` are real instants.
176
+ * Fires after the adapter stored the move, or — when the adapter refuses
177
+ * with a `read-only` error — so the host can store it itself.
178
+ */
139
179
  oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
180
+ /** Called with the active view id — once on mount, then on every change. */
140
181
  onviewchange?: (viewId: CalendarViewId) => void;
141
- /** Called when the focused date changes (navigation, drag-scroll, etc.) */
182
+ /**
183
+ * Called with the focused date — once on mount, then whenever it changes
184
+ * (navigation, scrolling, a tapped day). An instant on the focused day,
185
+ * like `currentDate` takes, so feeding it back is stable. With `timezone`
186
+ * read its date in that zone — `formatInTimeZone(d, timezone, …)` or
187
+ * `toZonedTime(d, timezone)`.
188
+ */
142
189
  ondatechange?: (date: Date) => void;
143
190
  /** Called when the pointer enters an event (hover). */
144
191
  oneventhover?: (event: TimelineEvent) => void;
145
- /** Called when a day cell is clicked (month grid; more views over time). */
192
+ /** Called when a day cell is clicked (month grid, agenda day heads). An instant, like `ondatechange`. Default: open that day in a day view. */
146
193
  ondayclick?: (date: Date) => void;
147
194
  /** Surfaced instead of silent console output when loading or mutations fail. */
148
195
  onerror?: (error: Error) => void;
@@ -154,6 +201,14 @@ interface Props {
154
201
  */
155
202
  timezone?: string;
156
203
  }
157
- declare const Calendar: Component<Props, {}, "">;
204
+ /**
205
+ * The built-in views — the registry a Calendar uses without a `views` prop.
206
+ * Spread it to add your own view and keep these:
207
+ * `views={[...defaultViews, { id: 'day-kanban', ... }]}`.
208
+ * On phones (`mobile: 'auto'`), every view except `*-agenda` and `*-mobile`
209
+ * swaps to the registered `{mode}-mobile` view, when there is one.
210
+ */
211
+ export declare const defaultViews: readonly CalendarView[];
212
+ declare const Calendar: Component<CalendarProps, {}, "">;
158
213
  type Calendar = ReturnType<typeof Calendar>;
159
214
  export default Calendar;
@@ -1,2 +1,2 @@
1
- export { default as Calendar } from './Calendar.svelte';
2
- export type { CalendarView } from './Calendar.svelte';
1
+ export { default as Calendar, defaultViews } from './Calendar.svelte';
2
+ export type { CalendarView, CalendarViewProps, CalendarProps } from './Calendar.svelte';
@@ -1,2 +1,2 @@
1
1
  // ─── Calendar shell barrel export ───────────────────────
2
- export { default as Calendar } from './Calendar.svelte';
2
+ export { default as Calendar, defaultViews } from './Calendar.svelte';
@@ -24,8 +24,9 @@ export function createClock(timezone) {
24
24
  // With a timezone, ticks are "zoned wall-clock" epoch ms — the same plane
25
25
  // the wrapped adapter shifts event Dates into. Views stay zone-agnostic.
26
26
  const now = () => (timezone ? toZonedTime(Date.now(), timezone).getTime() : Date.now());
27
- let tick = $state(now());
28
- let today = $state(sod(tick));
27
+ const t0 = now();
28
+ let tick = $state(t0);
29
+ let today = $state(sod(t0));
29
30
  let intervalId = null;
30
31
  function start() {
31
32
  intervalId = setInterval(() => {
@@ -20,6 +20,8 @@ export interface CalendarLabels {
20
20
  month: string;
21
21
  planner: string;
22
22
  agenda: string;
23
+ /** The scrolling-weeks view (`week-scroll`) in the view-type pills */
24
+ scroll: string;
23
25
  now: string;
24
26
  free: string;
25
27
  allDay: string;
@@ -48,6 +50,10 @@ export interface CalendarLabels {
48
50
  full: string;
49
51
  /** Event status: limited availability */
50
52
  limited: string;
53
+ /** A blocked slot without a label of its own */
54
+ unavailable: string;
55
+ /** Shown when a Calendar has no views to render */
56
+ noViews: string;
51
57
  dayNavigation: string;
52
58
  weekNavigation: string;
53
59
  dayPlanner: string;
@@ -114,7 +120,11 @@ export declare function fmtDay(ms: number, todayMs: number, opts?: {
114
120
  short?: boolean;
115
121
  }, locale?: string): string;
116
122
  /**
117
- * Format a week range label: "Feb 17 – 23, 2026" or "Jan 27 – Feb 2, 2026"
123
+ * Format a date range label in the locale's own order:
124
+ * "Sep 21 – 27, 2026" (en-US), "21–27 wrz 2026" (pl-PL),
125
+ * "Dec 28, 2026 – Jan 3, 2027" across a year.
126
+ *
127
+ * `weekEndMs` is the last day shown (inclusive); default: six days on.
118
128
  */
119
129
  export declare function fmtWeekRange(weekStartMs: number, locale?: string, weekEndMs?: number): string;
120
130
  /**
@@ -18,6 +18,7 @@ export const defaultLabels = {
18
18
  month: 'Month',
19
19
  planner: 'Planner',
20
20
  agenda: 'Agenda',
21
+ scroll: 'Scroll',
21
22
  now: 'now',
22
23
  free: 'free',
23
24
  allDay: 'All day',
@@ -42,6 +43,8 @@ export const defaultLabels = {
42
43
  tentative: 'tentative',
43
44
  full: 'full',
44
45
  limited: 'limited',
46
+ unavailable: 'Unavailable',
47
+ noViews: 'No views registered.',
45
48
  dayNavigation: 'Day navigation',
46
49
  weekNavigation: 'Week navigation',
47
50
  dayPlanner: 'Day planner',
@@ -165,23 +168,20 @@ export function fmtDay(ms, todayMs, opts, locale) {
165
168
  return new Date(ms).toLocaleDateString(loc, { weekday: 'short', month: 'short', day: 'numeric' });
166
169
  }
167
170
  /**
168
- * Format a week range label: "Feb 17 – 23, 2026" or "Jan 27 – Feb 2, 2026"
171
+ * Format a date range label in the locale's own order:
172
+ * "Sep 21 – 27, 2026" (en-US), "21–27 wrz 2026" (pl-PL),
173
+ * "Dec 28, 2026 – Jan 3, 2027" across a year.
174
+ *
175
+ * `weekEndMs` is the last day shown (inclusive); default: six days on.
169
176
  */
170
177
  export function fmtWeekRange(weekStartMs, locale, weekEndMs) {
171
178
  const loc = locale ?? defaultLocale;
172
179
  const s = new Date(weekStartMs);
173
180
  const e = new Date(weekEndMs ?? addDaysMs(weekStartMs, 6));
174
- const sm = s.toLocaleDateString(loc, { month: 'short' });
175
- const em = e.toLocaleDateString(loc, { month: 'short' });
176
- const sy = s.getFullYear();
177
- const ey = e.getFullYear();
178
- if (sy !== ey) {
179
- return `${sm} ${s.getDate()}, ${sy} – ${em} ${e.getDate()}, ${ey}`;
180
- }
181
- if (sm !== em) {
182
- return `${sm} ${s.getDate()} – ${em} ${e.getDate()}, ${ey}`;
183
- }
184
- return `${sm} ${s.getDate()} – ${e.getDate()}, ${ey}`;
181
+ const fmt = new Intl.DateTimeFormat(loc, { month: 'short', day: 'numeric', year: 'numeric' });
182
+ // Thin/narrow spaces around the dash vary by engine; plain spaces keep
183
+ // the label stable across browsers and in tests.
184
+ return fmt.formatRange(s, e).replace(/[\u2009\u202f]/g, ' ');
185
185
  }
186
186
  // ─── Shared time / duration formatting ──────────────────
187
187
  /**