@nomideusz/svelte-calendar 0.20.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +502 -267
- 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 +43 -25
- 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 +16 -16
- package/dist/core/time.d.ts +9 -0
- package/dist/core/time.js +12 -5
- package/dist/core/timezone.d.ts +7 -3
- package/dist/core/timezone.js +10 -4
- package/dist/engine/event-store.svelte.js +94 -57
- package/dist/engine/view-state.svelte.d.ts +5 -1
- package/dist/engine/view-state.svelte.js +8 -6
- package/dist/headless/create-agenda.svelte.d.ts +8 -1
- package/dist/headless/create-agenda.svelte.js +36 -18
- package/dist/headless/create-calendar.svelte.js +57 -21
- package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
- package/dist/headless/create-range-agenda.svelte.js +38 -17
- 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 +43 -35
- package/dist/views/agenda/AgendaWeek.svelte +75 -38
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +2 -0
- package/dist/views/mobile/MobileDay.svelte +99 -42
- package/dist/views/mobile/MobileWeek.svelte +31 -15
- package/dist/views/mobile/swipe.js +6 -1
- package/dist/views/month/MonthGrid.svelte +33 -21
- package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
- package/dist/views/planner/PlannerScroll.svelte +438 -164
- package/dist/views/planner/PlannerWeek.svelte +150 -50
- package/dist/views/shared/context.svelte.d.ts +1 -1
- package/package.json +24 -9
- package/widget/widget.js +10000 -9520
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { startOfWeek, DAY_MS } from '../core/time.js';
|
|
1
|
+
import { startOfWeek, addDaysMs, diffDays, DAY_MS, overlapsRange } from '../core/time.js';
|
|
2
2
|
import { VIVID_PALETTE } from '../core/palette.js';
|
|
3
|
+
import { CalendarReadOnlyError, EventNotFoundError } from './errors.js';
|
|
3
4
|
// ── Helpers ─────────────────────────────────────────────
|
|
4
5
|
/** Parse "HH:MM" into [hours, minutes] */
|
|
5
6
|
function parseTime(time) {
|
|
@@ -30,16 +31,23 @@ function parseDate(str) {
|
|
|
30
31
|
throw new Error(`Invalid date "${str}"`);
|
|
31
32
|
return date;
|
|
32
33
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Days from the start of the week to an ISO weekday (1=Mon … 7=Sun).
|
|
36
|
+
* Monday weeks: 1→0 … 7→6. Sunday weeks: 7→0, 1→1 … 6→6.
|
|
37
|
+
*/
|
|
38
|
+
function isoWeekdayToOffset(dayOfWeek, mondayStart) {
|
|
39
|
+
return mondayStart ? dayOfWeek - 1 : dayOfWeek % 7;
|
|
36
40
|
}
|
|
37
|
-
/**
|
|
38
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Normalize `dayOfWeek` to an array in week order — the projection walks it
|
|
43
|
+
* in order and stops at the first day past its bound, so Sunday must come
|
|
44
|
+
* first in a Sunday week.
|
|
45
|
+
*/
|
|
46
|
+
function normalizeDays(value, mondayStart) {
|
|
39
47
|
if (value == null)
|
|
40
48
|
return [];
|
|
41
49
|
const arr = Array.isArray(value) ? [...value] : [value];
|
|
42
|
-
return arr.sort((a, b) => a - b);
|
|
50
|
+
return arr.sort((a, b) => isoWeekdayToOffset(a, mondayStart) - isoWeekdayToOffset(b, mondayStart));
|
|
43
51
|
}
|
|
44
52
|
/** Clamp day to last day of month (e.g. 31 → 28 in Feb) */
|
|
45
53
|
function clampDayOfMonth(year, month, day) {
|
|
@@ -58,7 +66,9 @@ function createConcreteEvent(rec, date) {
|
|
|
58
66
|
const [sh, sm] = parseTime(rec.startTime);
|
|
59
67
|
const [eh, em] = parseTime(rec.endTime);
|
|
60
68
|
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm);
|
|
61
|
-
|
|
69
|
+
// An end before the start runs past midnight (22:00–02:00).
|
|
70
|
+
const overnight = eh * 60 + em < sh * 60 + sm;
|
|
71
|
+
const end = new Date(date.getFullYear(), date.getMonth(), date.getDate() + (overnight ? 1 : 0), eh, em);
|
|
62
72
|
return {
|
|
63
73
|
id: `${rec.id}--${dateKey(date)}`,
|
|
64
74
|
title: rec.title,
|
|
@@ -91,7 +101,7 @@ function computeUntilFromCount(rec, startDateObj, mondayStart) {
|
|
|
91
101
|
return d;
|
|
92
102
|
}
|
|
93
103
|
case 'weekly': {
|
|
94
|
-
const days = normalizeDays(rec.dayOfWeek);
|
|
104
|
+
const days = normalizeDays(rec.dayOfWeek, mondayStart);
|
|
95
105
|
if (days.length === 0)
|
|
96
106
|
return undefined;
|
|
97
107
|
const startMs = startDateObj.getTime();
|
|
@@ -100,20 +110,24 @@ function computeUntilFromCount(rec, startDateObj, mondayStart) {
|
|
|
100
110
|
const maxCycles = Math.ceil(count / days.length) + 2;
|
|
101
111
|
for (let c = 0; c < maxCycles; c++) {
|
|
102
112
|
for (const day of days) {
|
|
103
|
-
const dayMs = weekMs
|
|
113
|
+
const dayMs = addDaysMs(weekMs, isoWeekdayToOffset(day, mondayStart));
|
|
104
114
|
if (dayMs >= startMs) {
|
|
105
115
|
remaining--;
|
|
106
116
|
if (remaining === 0)
|
|
107
117
|
return new Date(dayMs);
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
|
-
weekMs
|
|
120
|
+
weekMs = addDaysMs(weekMs, interval * 7);
|
|
111
121
|
}
|
|
112
122
|
return undefined;
|
|
113
123
|
}
|
|
114
124
|
case 'monthly': {
|
|
125
|
+
// The start month counts only if its occurrence is on or after
|
|
126
|
+
// startDate (20 Jan with dayOfMonth 15 begins in February).
|
|
127
|
+
const first = clampDayOfMonth(startDateObj.getFullYear(), startDateObj.getMonth(), rec.dayOfMonth ?? 1);
|
|
115
128
|
const totalMonths = startDateObj.getFullYear() * 12 +
|
|
116
129
|
startDateObj.getMonth() +
|
|
130
|
+
(first < startDateObj ? interval : 0) +
|
|
117
131
|
(count - 1) * interval;
|
|
118
132
|
const y = Math.floor(totalMonths / 12);
|
|
119
133
|
const m = totalMonths % 12;
|
|
@@ -150,7 +164,7 @@ function projectDaily(rec, range, startDate, effectiveUntil, out) {
|
|
|
150
164
|
}
|
|
151
165
|
function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out) {
|
|
152
166
|
const interval = rec.interval ?? 1;
|
|
153
|
-
const days = normalizeDays(rec.dayOfWeek);
|
|
167
|
+
const days = normalizeDays(rec.dayOfWeek, mondayStart);
|
|
154
168
|
if (days.length === 0)
|
|
155
169
|
return;
|
|
156
170
|
const startDateMs = startDate?.getTime();
|
|
@@ -165,16 +179,16 @@ function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out)
|
|
|
165
179
|
weekMs = anchorWeekMs;
|
|
166
180
|
}
|
|
167
181
|
else {
|
|
168
|
-
const weeksBetween = Math.floor((rangeStartMs
|
|
182
|
+
const weeksBetween = Math.floor(diffDays(rangeStartMs, anchorWeekMs) / 7);
|
|
169
183
|
const skip = Math.floor(weeksBetween / interval);
|
|
170
|
-
weekMs = anchorWeekMs
|
|
171
|
-
if (weekMs
|
|
172
|
-
weekMs
|
|
184
|
+
weekMs = addDaysMs(anchorWeekMs, skip * interval * 7);
|
|
185
|
+
if (addDaysMs(weekMs, 7) <= rangeStartMs) {
|
|
186
|
+
weekMs = addDaysMs(weekMs, interval * 7);
|
|
173
187
|
}
|
|
174
188
|
}
|
|
175
189
|
while (weekMs < rangeEndMs) {
|
|
176
190
|
for (const day of days) {
|
|
177
|
-
const dayMs = weekMs
|
|
191
|
+
const dayMs = addDaysMs(weekMs, isoWeekdayToOffset(day, mondayStart));
|
|
178
192
|
if (startDateMs != null && dayMs < startDateMs)
|
|
179
193
|
continue;
|
|
180
194
|
if (dayMs >= rangeEndMs)
|
|
@@ -187,7 +201,7 @@ function projectWeekly(rec, range, startDate, effectiveUntil, mondayStart, out)
|
|
|
187
201
|
out.push(ev);
|
|
188
202
|
}
|
|
189
203
|
}
|
|
190
|
-
weekMs
|
|
204
|
+
weekMs = addDaysMs(weekMs, interval * 7);
|
|
191
205
|
}
|
|
192
206
|
}
|
|
193
207
|
function projectMonthly(rec, range, startDate, effectiveUntil, out) {
|
|
@@ -210,7 +224,7 @@ function projectMonthly(rec, range, startDate, effectiveUntil, out) {
|
|
|
210
224
|
break;
|
|
211
225
|
if (effectiveUntil && date > effectiveUntil)
|
|
212
226
|
break;
|
|
213
|
-
if (
|
|
227
|
+
if (!startDate || date >= startDate) {
|
|
214
228
|
const ev = createConcreteEvent(rec, date);
|
|
215
229
|
if (ev.start < range.end && ev.end > range.start) {
|
|
216
230
|
out.push(ev);
|
|
@@ -254,6 +268,10 @@ export function createRecurringAdapter(schedule, options = {}) {
|
|
|
254
268
|
}
|
|
255
269
|
const fetchEventsSync = (range) => {
|
|
256
270
|
const events = [];
|
|
271
|
+
// Project from a day earlier: an occurrence that runs past midnight
|
|
272
|
+
// (22:00–02:00) overlaps the range from the day before it. The
|
|
273
|
+
// overlap filter below trims back to the requested range.
|
|
274
|
+
const projected = { start: new Date(addDaysMs(range.start.getTime(), -1)), end: range.end };
|
|
257
275
|
for (const rec of schedule) {
|
|
258
276
|
const colored = { ...rec, color: resolveColor(rec) };
|
|
259
277
|
const freq = rec.frequency ?? 'weekly';
|
|
@@ -272,13 +290,13 @@ export function createRecurringAdapter(schedule, options = {}) {
|
|
|
272
290
|
}
|
|
273
291
|
switch (freq) {
|
|
274
292
|
case 'daily':
|
|
275
|
-
projectDaily(colored,
|
|
293
|
+
projectDaily(colored, projected, sd, effectiveUntil, events);
|
|
276
294
|
break;
|
|
277
295
|
case 'weekly':
|
|
278
|
-
projectWeekly(colored,
|
|
296
|
+
projectWeekly(colored, projected, sd, effectiveUntil, mondayStart, events);
|
|
279
297
|
break;
|
|
280
298
|
case 'monthly':
|
|
281
|
-
projectMonthly(colored,
|
|
299
|
+
projectMonthly(colored, projected, sd, effectiveUntil, events);
|
|
282
300
|
break;
|
|
283
301
|
}
|
|
284
302
|
}
|
|
@@ -288,7 +306,7 @@ export function createRecurringAdapter(schedule, options = {}) {
|
|
|
288
306
|
for (const d of rec.excludeDates ?? [])
|
|
289
307
|
skipped.add(`${rec.id}--${d.replace(/-/g, '')}`);
|
|
290
308
|
}
|
|
291
|
-
const kept =
|
|
309
|
+
const kept = events.filter((e) => !skipped.has(e.id) && overlapsRange(e, range.start, range.end));
|
|
292
310
|
return movable ? kept : kept.map((e) => ({ ...e, data: { ...e.data, readOnly: true } }));
|
|
293
311
|
};
|
|
294
312
|
return {
|
|
@@ -303,9 +321,9 @@ export function createRecurringAdapter(schedule, options = {}) {
|
|
|
303
321
|
async updateEvent(id) {
|
|
304
322
|
const ruleId = id.split('--')[0];
|
|
305
323
|
if (!schedule.some((rec) => rec.id === ruleId)) {
|
|
306
|
-
throw new
|
|
324
|
+
throw new EventNotFoundError(id);
|
|
307
325
|
}
|
|
308
|
-
throw new
|
|
326
|
+
throw new CalendarReadOnlyError(`read-only: ${id} is a projected occurrence. Add its date to the rule's excludeDates and store the change yourself.`);
|
|
309
327
|
},
|
|
310
328
|
};
|
|
311
329
|
}
|
package/dist/adapters/rest.d.ts
CHANGED
|
@@ -12,9 +12,9 @@ export interface RestAdapterOptions {
|
|
|
12
12
|
baseUrl: string;
|
|
13
13
|
/** Custom headers (e.g. Authorization) */
|
|
14
14
|
headers?: Record<string, string>;
|
|
15
|
-
/** Map API response to TimelineEvent[] */
|
|
15
|
+
/** Map API response to TimelineEvent[] (default: the body is an array of events, `start`/`end` parsed into Dates) */
|
|
16
16
|
mapEvents?: (data: unknown) => TimelineEvent[];
|
|
17
|
-
/** Map API response to a single TimelineEvent */
|
|
17
|
+
/** Map API response to a single TimelineEvent (default: the body is the event, `start`/`end` parsed into Dates) */
|
|
18
18
|
mapEvent?: (data: unknown) => TimelineEvent;
|
|
19
19
|
}
|
|
20
20
|
export declare function createRestAdapter(options: RestAdapterOptions): CalendarAdapter;
|
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
|
|