@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.
- package/README.md +502 -269
- package/dist/adapters/composite.d.ts +12 -1
- package/dist/adapters/composite.js +109 -47
- package/dist/adapters/errors.d.ts +22 -0
- package/dist/adapters/errors.js +32 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/jmap.js +26 -17
- package/dist/adapters/mapped.d.ts +2 -2
- package/dist/adapters/mapped.js +18 -9
- package/dist/adapters/memory.d.ts +1 -1
- package/dist/adapters/memory.js +5 -3
- package/dist/adapters/recurring.d.ts +1 -1
- package/dist/adapters/recurring.js +37 -19
- package/dist/adapters/rest.d.ts +2 -2
- package/dist/adapters/rest.js +9 -4
- package/dist/calendar/Calendar.svelte +122 -78
- package/dist/calendar/Calendar.svelte.d.ts +69 -14
- package/dist/calendar/index.d.ts +2 -2
- package/dist/calendar/index.js +1 -1
- package/dist/core/clock.svelte.js +3 -2
- package/dist/core/locale.d.ts +11 -1
- package/dist/core/locale.js +12 -12
- package/dist/core/time.d.ts +9 -0
- package/dist/core/time.js +8 -0
- package/dist/core/timezone.d.ts +7 -3
- package/dist/core/timezone.js +10 -4
- package/dist/engine/event-store.svelte.js +93 -56
- package/dist/engine/view-state.svelte.d.ts +5 -1
- package/dist/engine/view-state.svelte.js +5 -3
- package/dist/headless/create-agenda.svelte.d.ts +8 -1
- package/dist/headless/create-agenda.svelte.js +32 -14
- package/dist/headless/create-calendar.svelte.js +53 -17
- package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
- package/dist/headless/create-range-agenda.svelte.js +33 -12
- package/dist/headless/index.d.ts +1 -0
- package/dist/index.d.ts +10 -7
- package/dist/index.js +8 -5
- package/dist/primitives/DayHeader.svelte +3 -2
- package/dist/primitives/DayHeader.svelte.d.ts +1 -1
- package/dist/primitives/EmptySlot.svelte +5 -2
- package/dist/primitives/EventBlock.svelte +21 -14
- package/dist/primitives/FloatingPanel.svelte +110 -15
- package/dist/primitives/FloatingPanel.svelte.d.ts +7 -5
- package/dist/primitives/NowIndicator.svelte +11 -4
- package/dist/primitives/TimeGutter.svelte +10 -6
- package/dist/primitives/index.d.ts +1 -0
- package/dist/text-fit.d.ts +8 -2
- package/dist/views/agenda/AgendaDay.svelte +41 -33
- package/dist/views/agenda/AgendaWeek.svelte +71 -34
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +2 -0
- package/dist/views/mobile/MobileDay.svelte +98 -41
- package/dist/views/mobile/MobileWeek.svelte +29 -13
- package/dist/views/mobile/swipe.js +6 -1
- package/dist/views/month/MonthGrid.svelte +29 -17
- package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
- package/dist/views/planner/PlannerScroll.svelte +198 -61
- package/dist/views/planner/PlannerWeek.svelte +148 -48
- package/dist/views/shared/context.svelte.d.ts +1 -1
- package/package.json +29 -14
- package/widget/widget.js +7983 -7688
- package/dist/assets/favicon.svg +0 -9
- package/dist/widget/CalendarWidget.svelte +0 -114
- package/dist/widget/CalendarWidget.svelte.d.ts +0 -37
- package/dist/widget/index.d.ts +0 -1
- package/dist/widget/index.js +0 -1
- package/dist/widget/widget.d.ts +0 -10
- package/dist/widget/widget.js +0 -96
package/dist/adapters/rest.js
CHANGED
|
@@ -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 ??
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
if (mode === "resize-start")
|
|
188
|
-
|
|
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
|
|
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
|
|
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 (
|
|
257
|
+
if (isReadOnlyError(e)) {
|
|
236
258
|
const ev = store.byId(payload.eventId);
|
|
237
259
|
if (ev) effectiveMove?.(ev, start, end);
|
|
238
|
-
} else if (!
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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
|
|
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
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
ondatechange
|
|
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.
|
|
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={
|
|
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">
|
|
766
|
+
<div class="cal-empty">{L.noViews}</div>
|
|
723
767
|
{/if}
|
|
724
768
|
</div>
|
|
725
769
|
|
|
@@ -1,25 +1,57 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
/**
|
|
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<[
|
|
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<[
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/calendar/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/calendar/index.js
CHANGED
|
@@ -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
|
-
|
|
28
|
-
let
|
|
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(() => {
|
package/dist/core/locale.d.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/core/locale.js
CHANGED
|
@@ -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
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
/**
|