@nomideusz/svelte-calendar 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +19 -4
  2. package/dist/calendar/Calendar.svelte +364 -497
  3. package/dist/calendar/Calendar.svelte.d.ts +6 -2
  4. package/dist/core/clock.svelte.js +12 -5
  5. package/dist/core/locale.d.ts +4 -0
  6. package/dist/core/locale.js +4 -0
  7. package/dist/engine/view-state.svelte.d.ts +2 -2
  8. package/dist/engine/view-state.svelte.js +20 -0
  9. package/dist/headless/create-calendar.svelte.js +18 -2
  10. package/dist/headless/types.d.ts +6 -6
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +3 -0
  13. package/dist/primitives/DayHeader.svelte +9 -25
  14. package/dist/primitives/EmptySlot.svelte +15 -31
  15. package/dist/primitives/EventBlock.svelte +33 -61
  16. package/dist/primitives/NowIndicator.svelte +16 -42
  17. package/dist/primitives/TimeGutter.svelte +9 -23
  18. package/dist/views/agenda/Agenda.svelte +3 -10
  19. package/dist/views/agenda/AgendaDay.svelte +195 -167
  20. package/dist/views/agenda/AgendaWeek.svelte +141 -195
  21. package/dist/views/mobile/Mobile.svelte +3 -10
  22. package/dist/views/mobile/MobileDay.svelte +422 -266
  23. package/dist/views/mobile/MobileWeek.svelte +131 -186
  24. package/dist/views/month/MonthGrid.svelte +334 -0
  25. package/dist/views/month/MonthGrid.svelte.d.ts +15 -0
  26. package/dist/views/planner/Planner.svelte +3 -10
  27. package/dist/views/planner/PlannerDay.svelte +645 -549
  28. package/dist/views/planner/PlannerWeek.svelte +299 -412
  29. package/dist/views/shared/EventContent.svelte +16 -0
  30. package/dist/views/shared/EventContent.svelte.d.ts +9 -0
  31. package/dist/views/shared/context.svelte.d.ts +1 -0
  32. package/dist/views/shared/context.svelte.js +1 -0
  33. package/dist/widget/CalendarWidget.svelte +79 -116
  34. package/package.json +3 -2
  35. package/widget/svelte-calendar.css +329 -13
  36. package/widget/widget.js +1945 -1031
@@ -5,271 +5,344 @@
5
5
  Large touch targets, swipe left/right to change day.
6
6
  Events positioned absolutely within hour lanes.
7
7
  -->
8
- <script lang="ts">
9
- import { onMount } from 'svelte';
10
- import { useCalendarContext } from '../shared/context.svelte.js';
11
- import { createClock } from '../../core/clock.svelte.js';
12
- import type { TimelineEvent, BlockedSlot } from '../../core/types.js';
13
- import { DAY_MS, HOUR_MS, sod, isAllDay, isMultiDay, segmentForDay } from '../../core/time.js';
14
- import type { DaySegment } from '../../core/time.js';
15
- import { fmtH, fmtTime, getLabels } from '../../core/locale.js';
16
-
17
- const L = $derived(getLabels());
18
-
19
- interface Props {
20
- height?: number | null;
21
- events?: TimelineEvent[];
22
- style?: string;
23
- focusDate?: Date;
24
- locale?: string;
25
- oneventclick?: (event: TimelineEvent) => void;
26
- oneventcreate?: (range: { start: Date; end: Date }) => void;
27
- selectedEventId?: string | null;
28
- readOnly?: boolean;
29
- visibleHours?: [number, number];
30
- [key: string]: unknown;
31
- }
32
-
33
- let {
34
- height = null,
35
- events = [],
36
- style = '',
37
- locale,
38
- focusDate,
39
- oneventclick,
40
- oneventcreate,
41
- selectedEventId = null,
42
- readOnly = false,
43
- visibleHours,
44
- }: Props = $props();
45
-
46
- // ── Context ────────────────────────────────────────
47
- const ctx = useCalendarContext();
48
- const viewState = $derived(ctx.viewState);
49
- const autoHeight = $derived(ctx.autoHeight);
50
- const oneventhover = $derived(ctx.oneventhover);
51
- const disabledSet = $derived(ctx.disabledSet);
52
- const loadRangeCtx = $derived(ctx.loadRange);
53
- const minDuration = $derived(ctx.minDuration);
54
- const blockedSlots = $derived(ctx.blockedSlots);
55
-
56
- const clock = createClock();
57
-
58
- // ── Config ─────────────────────────────────────────
59
- const HOUR_HEIGHT = 64;
60
- const GUTTER_W = 40;
61
- const startHour = $derived(visibleHours?.[0] ?? 0);
62
- const endHour = $derived(visibleHours?.[1] ?? 24);
63
- const hourCount = $derived(Math.max(1, endHour - startHour));
64
- const gridHeight = $derived(hourCount * HOUR_HEIGHT);
65
-
66
- // ── Day state ──────────────────────────────────────
67
- const dayMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
68
- const dayEnd = $derived(dayMs + DAY_MS);
69
- const isToday = $derived(dayMs === clock.today);
70
- const isPast = $derived(dayMs < clock.today);
71
- const isDisabled = $derived(disabledSet.has(dayMs));
72
-
73
- // ── Load range ─────────────────────────────────────
74
- $effect(() => {
75
- if (!loadRangeCtx) return;
76
- const rangeStart = new Date(dayMs - 2 * DAY_MS);
77
- const rangeEnd = new Date(dayMs + 3 * DAY_MS);
78
- loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
79
- return () => loadRangeCtx.set(null);
80
- });
81
-
82
- // ── Events partition ───────────────────────────────
83
- const timedEvents = $derived(
84
- events
85
- .filter(ev => !isAllDay(ev) && !isMultiDay(ev) && ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs)
86
- .sort((a, b) => a.start.getTime() - b.start.getTime())
87
- );
88
-
89
- const allDayEvents = $derived.by(() => {
90
- const segs: DaySegment[] = [];
91
- for (const ev of events) {
92
- if (!isAllDay(ev) && !isMultiDay(ev)) continue;
93
- const seg = segmentForDay(ev, dayMs);
94
- if (seg) segs.push(seg);
95
- }
96
- return segs;
97
- });
98
-
99
- // ── Positioned events ──────────────────────────────
100
- interface PosEvent {
101
- ev: TimelineEvent;
102
- top: number;
103
- height: number;
104
- left: string;
105
- width: string;
106
- isCurrent: boolean;
107
- isNext: boolean;
108
- col: number;
109
- totalCols: number;
110
- }
111
-
112
- const positionedEvents = $derived.by(() => {
113
- const now = clock.tick;
114
- const sorted = [...timedEvents];
115
-
116
- // Find the next upcoming event today
117
- let nextEventId: string | null = null;
118
- if (isToday) {
119
- for (const ev of [...sorted].sort((a, b) => a.start.getTime() - b.start.getTime())) {
120
- const s = ev.start.getTime();
121
- if (s > now) { nextEventId = ev.id; break; }
122
- }
123
- }
124
-
125
- // Overlap grouping
126
- const infos = sorted.map(ev => {
127
- const sMs = Math.max(ev.start.getTime(), dayMs + startHour * HOUR_MS);
128
- const eMs = Math.min(ev.end.getTime(), dayMs + endHour * HOUR_MS);
129
- const topH = (sMs - dayMs) / HOUR_MS - startHour;
130
- const botH = (eMs - dayMs) / HOUR_MS - startHour;
131
- return {
132
- ev,
133
- top: topH * HOUR_HEIGHT,
134
- height: Math.max(24, (botH - topH) * HOUR_HEIGHT),
135
- isCurrent: ev.start.getTime() <= now && ev.end.getTime() > now,
136
- isNext: ev.id === nextEventId,
137
- startMs: sMs,
138
- endMs: eMs,
139
- col: 0,
140
- totalCols: 1,
141
- };
142
- });
143
-
144
- // Assign columns for overlapping events
145
- const par = infos.map((_, i) => i);
146
- function find(i: number): number {
147
- while (par[i] !== i) { par[i] = par[par[i]]; i = par[i]; }
148
- return i;
149
- }
150
- for (let i = 0; i < infos.length; i++) {
151
- for (let j = i + 1; j < infos.length; j++) {
152
- if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
153
- else break;
154
- }
155
- }
156
-
157
- const groups = new Map<number, number[]>();
158
- for (let i = 0; i < infos.length; i++) {
159
- const root = find(i);
160
- if (!groups.has(root)) groups.set(root, []);
161
- groups.get(root)!.push(i);
162
- }
163
-
164
- for (const [, indices] of groups) {
165
- const rows: number[] = [];
166
- for (const idx of indices) {
167
- let row = 0;
168
- for (let r = 0; r < rows.length; r++) {
169
- if (rows[r] <= infos[idx].startMs) { row = r; rows[r] = infos[idx].endMs; break; }
170
- row = r + 1;
171
- }
172
- if (row >= rows.length) rows.push(infos[idx].endMs);
173
- infos[idx].col = row;
174
- }
175
- for (const idx of indices) infos[idx].totalCols = rows.length;
176
- }
177
-
178
- return infos.map(info => ({
179
- ev: info.ev,
180
- top: info.top,
181
- height: info.height,
182
- left: `calc(${GUTTER_W}px + ${(info.col / info.totalCols) * 100}% - ${(GUTTER_W * info.col) / info.totalCols}px)`,
183
- width: `calc(${100 / info.totalCols}% - ${GUTTER_W / info.totalCols + 2}px)`,
184
- isCurrent: info.isCurrent,
185
- isNext: info.isNext,
186
- col: info.col,
187
- totalCols: info.totalCols,
188
- })) as PosEvent[];
189
- });
190
-
191
- // ── Now indicator ──────────────────────────────────
192
- const nowOffset = $derived.by(() => {
193
- if (!isToday) return -1;
194
- const h = (clock.tick - dayMs) / HOUR_MS - startHour;
195
- if (h < 0 || h > hourCount) return -1;
196
- return h * HOUR_HEIGHT;
197
- });
198
-
199
- // ── Blocked slot check ─────────────────────────────
200
- function isBlockedAt(hour: number): boolean {
201
- if (!blockedSlots?.length) return false;
202
- const jsDay = new Date(dayMs).getDay();
203
- const isoDay = jsDay === 0 ? 7 : jsDay;
204
- return blockedSlots.some(slot => {
205
- if (slot.day && slot.day !== isoDay) return false;
206
- return hour >= slot.start && hour < slot.end;
207
- });
208
- }
209
-
210
- // ── Touch swipe navigation ─────────────────────────
211
- let el: HTMLDivElement;
212
- let touchStartX = 0;
213
- let touchStartY = 0;
214
- let swiping = false;
215
- let swipeOffset = $state(0);
216
- const SWIPE_THRESHOLD = 50;
217
-
218
- function onTouchStart(e: TouchEvent) {
219
- const t = e.touches[0];
220
- touchStartX = t.clientX;
221
- touchStartY = t.clientY;
222
- swiping = true;
223
- swipeOffset = 0;
224
- }
225
-
226
- function onTouchMove(e: TouchEvent) {
227
- if (!swiping) return;
228
- const t = e.touches[0];
229
- const dx = t.clientX - touchStartX;
230
- const dy = t.clientY - touchStartY;
231
- // Only swipe if horizontal movement dominates
232
- if (Math.abs(dy) > Math.abs(dx) * 0.8) { swiping = false; return; }
233
- swipeOffset = dx;
234
- }
235
-
236
- function onTouchEnd() {
237
- if (!swiping) { swipeOffset = 0; return; }
238
- if (Math.abs(swipeOffset) > SWIPE_THRESHOLD) {
239
- if (swipeOffset > 0) {
240
- viewState?.prev();
241
- } else {
242
- viewState?.next();
243
- }
244
- }
245
- swipeOffset = 0;
246
- swiping = false;
247
- }
248
-
249
- // ── Click-to-create ────────────────────────────────
250
- function handleGridClick(e: MouseEvent) {
251
- if (!oneventcreate || readOnly || isDisabled) return;
252
- if ((e.target as HTMLElement).closest('.mb-event')) return;
253
- const grid = (e.currentTarget as HTMLElement);
254
- const rect = grid.getBoundingClientRect();
255
- const y = e.clientY - rect.top + grid.scrollTop;
256
- const hour = startHour + y / HOUR_HEIGHT;
257
- if (isBlockedAt(hour)) return;
258
- const snapHour = Math.floor(hour);
259
- const durMin = minDuration ? Math.max(60, minDuration) : 60;
260
- const start = new Date(dayMs + snapHour * HOUR_MS);
261
- const end = new Date(start.getTime() + durMin * 60_000);
262
- oneventcreate({ start, end });
263
- }
264
-
265
- // ── Auto-scroll to now ─────────────────────────────
266
- let gridEl: HTMLDivElement;
267
- onMount(() => {
268
- if (nowOffset > 0 && gridEl) {
269
- const scrollTarget = Math.max(0, nowOffset - 120);
270
- gridEl.scrollTop = scrollTarget;
271
- }
272
- });
8
+ <script lang="ts">import { onMount } from "svelte";
9
+ import { useCalendarContext } from "../shared/context.svelte.js";
10
+ import EventContent from "../shared/EventContent.svelte";
11
+ import { createClock } from "../../core/clock.svelte.js";
12
+ import { DAY_MS, HOUR_MS, sod, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
13
+ import { fmtH, fmtTime, getLabels } from "../../core/locale.js";
14
+ const L = $derived(getLabels());
15
+ let {
16
+ height = null,
17
+ events = [],
18
+ style = "",
19
+ locale,
20
+ focusDate,
21
+ oneventclick,
22
+ oneventcreate,
23
+ selectedEventId = null,
24
+ readOnly = false,
25
+ visibleHours
26
+ } = $props();
27
+ const ctx = useCalendarContext();
28
+ const viewState = $derived(ctx.viewState);
29
+ const autoHeight = $derived(ctx.autoHeight);
30
+ const oneventhover = $derived(ctx.oneventhover);
31
+ const disabledSet = $derived(ctx.disabledSet);
32
+ const loadRangeCtx = $derived(ctx.loadRange);
33
+ const minDuration = $derived(ctx.minDuration);
34
+ const blockedSlots = $derived(ctx.blockedSlots);
35
+ const drag = $derived(ctx.drag);
36
+ const commitDragCtx = $derived(ctx.commitDrag);
37
+ const SNAP_MS = $derived(ctx.snapInterval * 6e4);
38
+ const clock = createClock();
39
+ const HOUR_HEIGHT = 64;
40
+ const GUTTER_W = 40;
41
+ const startHour = $derived(visibleHours?.[0] ?? 0);
42
+ const endHour = $derived(visibleHours?.[1] ?? 24);
43
+ const hourCount = $derived(Math.max(1, endHour - startHour));
44
+ const gridHeight = $derived(hourCount * HOUR_HEIGHT);
45
+ const dayMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
46
+ const dayEnd = $derived(dayMs + DAY_MS);
47
+ const isToday = $derived(dayMs === clock.today);
48
+ const isPast = $derived(dayMs < clock.today);
49
+ const isDisabled = $derived(disabledSet.has(dayMs));
50
+ $effect(() => {
51
+ if (!loadRangeCtx) return;
52
+ const rangeStart = new Date(dayMs - 2 * DAY_MS);
53
+ const rangeEnd = new Date(dayMs + 3 * DAY_MS);
54
+ loadRangeCtx.set({ start: rangeStart, end: rangeEnd });
55
+ return () => loadRangeCtx.set(null);
56
+ });
57
+ const timedEvents = $derived(
58
+ events.filter((ev) => !isAllDay(ev) && !isMultiDay(ev) && ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs).sort((a, b) => a.start.getTime() - b.start.getTime())
59
+ );
60
+ const allDayEvents = $derived.by(() => {
61
+ const segs = [];
62
+ for (const ev of events) {
63
+ if (!isAllDay(ev) && !isMultiDay(ev)) continue;
64
+ const seg = segmentForDay(ev, dayMs);
65
+ if (seg) segs.push(seg);
66
+ }
67
+ return segs;
68
+ });
69
+ const positionedEvents = $derived.by(() => {
70
+ const now = clock.tick;
71
+ const sorted = [...timedEvents];
72
+ const rsP = drag?.active && (drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
73
+ let nextEventId = null;
74
+ if (isToday) {
75
+ for (const ev of [...sorted].sort((a, b) => a.start.getTime() - b.start.getTime())) {
76
+ const s = ev.start.getTime();
77
+ if (s > now) {
78
+ nextEventId = ev.id;
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ const infos = sorted.map((ev) => {
84
+ const resizing = rsP?.eventId === ev.id;
85
+ const evStart = resizing ? rsP.start : ev.start;
86
+ const evEnd = resizing ? rsP.end : ev.end;
87
+ const sMs = Math.max(evStart.getTime(), dayMs + startHour * HOUR_MS);
88
+ const eMs = Math.min(evEnd.getTime(), dayMs + endHour * HOUR_MS);
89
+ const topH = (sMs - dayMs) / HOUR_MS - startHour;
90
+ const botH = (eMs - dayMs) / HOUR_MS - startHour;
91
+ return {
92
+ ev,
93
+ top: topH * HOUR_HEIGHT,
94
+ height: Math.max(24, (botH - topH) * HOUR_HEIGHT),
95
+ isCurrent: ev.start.getTime() <= now && ev.end.getTime() > now,
96
+ isNext: ev.id === nextEventId,
97
+ isResizing: resizing,
98
+ startMs: sMs,
99
+ endMs: eMs,
100
+ col: 0,
101
+ totalCols: 1
102
+ };
103
+ });
104
+ const par = infos.map((_, i) => i);
105
+ function find(i) {
106
+ while (par[i] !== i) {
107
+ par[i] = par[par[i]];
108
+ i = par[i];
109
+ }
110
+ return i;
111
+ }
112
+ for (let i = 0; i < infos.length; i++) {
113
+ for (let j = i + 1; j < infos.length; j++) {
114
+ if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
115
+ else break;
116
+ }
117
+ }
118
+ const groups = /* @__PURE__ */ new Map();
119
+ for (let i = 0; i < infos.length; i++) {
120
+ const root = find(i);
121
+ if (!groups.has(root)) groups.set(root, []);
122
+ groups.get(root).push(i);
123
+ }
124
+ for (const [, indices] of groups) {
125
+ const rows = [];
126
+ for (const idx of indices) {
127
+ let row = 0;
128
+ for (let r = 0; r < rows.length; r++) {
129
+ if (rows[r] <= infos[idx].startMs) {
130
+ row = r;
131
+ rows[r] = infos[idx].endMs;
132
+ break;
133
+ }
134
+ row = r + 1;
135
+ }
136
+ if (row >= rows.length) rows.push(infos[idx].endMs);
137
+ infos[idx].col = row;
138
+ }
139
+ for (const idx of indices) infos[idx].totalCols = rows.length;
140
+ }
141
+ return infos.map((info) => ({
142
+ ev: info.ev,
143
+ top: info.top,
144
+ height: info.height,
145
+ left: `calc(${GUTTER_W}px + ${info.col / info.totalCols * 100}% - ${GUTTER_W * info.col / info.totalCols}px)`,
146
+ width: `calc(${100 / info.totalCols}% - ${GUTTER_W / info.totalCols + 2}px)`,
147
+ isCurrent: info.isCurrent,
148
+ isNext: info.isNext,
149
+ isResizing: info.isResizing,
150
+ col: info.col,
151
+ totalCols: info.totalCols
152
+ }));
153
+ });
154
+ const nowOffset = $derived.by(() => {
155
+ if (!isToday) return -1;
156
+ const h = (clock.tick - dayMs) / HOUR_MS - startHour;
157
+ if (h < 0 || h > hourCount) return -1;
158
+ return h * HOUR_HEIGHT;
159
+ });
160
+ function isBlockedAt(hour) {
161
+ if (!blockedSlots?.length) return false;
162
+ const jsDay = new Date(dayMs).getDay();
163
+ const isoDay = jsDay === 0 ? 7 : jsDay;
164
+ return blockedSlots.some((slot) => {
165
+ if (slot.day && slot.day !== isoDay) return false;
166
+ return hour >= slot.start && hour < slot.end;
167
+ });
168
+ }
169
+ let el;
170
+ let touchStartX = 0;
171
+ let touchStartY = 0;
172
+ let swiping = false;
173
+ let swipeOffset = $state(0);
174
+ const SWIPE_THRESHOLD = 50;
175
+ function onTouchStart(e) {
176
+ const t = e.touches[0];
177
+ touchStartX = t.clientX;
178
+ touchStartY = t.clientY;
179
+ swiping = true;
180
+ swipeOffset = 0;
181
+ }
182
+ function onTouchMove(e) {
183
+ if (!swiping) return;
184
+ const t = e.touches[0];
185
+ const dx = t.clientX - touchStartX;
186
+ const dy = t.clientY - touchStartY;
187
+ if (Math.abs(dy) > Math.abs(dx) * 0.8) {
188
+ swiping = false;
189
+ return;
190
+ }
191
+ swipeOffset = dx;
192
+ }
193
+ function onTouchEnd() {
194
+ if (!swiping) {
195
+ swipeOffset = 0;
196
+ return;
197
+ }
198
+ if (Math.abs(swipeOffset) > SWIPE_THRESHOLD) {
199
+ if (swipeOffset > 0) {
200
+ viewState?.prev();
201
+ } else {
202
+ viewState?.next();
203
+ }
204
+ }
205
+ swipeOffset = 0;
206
+ swiping = false;
207
+ }
208
+ function handleGridClick(e) {
209
+ if (suppressGridClick) {
210
+ suppressGridClick = false;
211
+ return;
212
+ }
213
+ if (!oneventcreate || readOnly || isDisabled) return;
214
+ if (e.target.closest(".mb-event")) return;
215
+ const grid = e.currentTarget;
216
+ const rect = grid.getBoundingClientRect();
217
+ const y = e.clientY - rect.top + grid.scrollTop;
218
+ const hour = startHour + y / HOUR_HEIGHT;
219
+ if (isBlockedAt(hour)) return;
220
+ const snapHour = Math.floor(hour);
221
+ const durMin = minDuration ? Math.max(60, minDuration) : 60;
222
+ const start = new Date(dayMs + snapHour * HOUR_MS);
223
+ const end = new Date(start.getTime() + durMin * 6e4);
224
+ oneventcreate({ start, end });
225
+ }
226
+ const CREATE_THRESHOLD = 4;
227
+ let suppressGridClick = false;
228
+ let mbCreateStartY = 0;
229
+ let mbCreateAnchorMs = 0;
230
+ let mbCreateStarted = false;
231
+ function gridTimeMs(clientY) {
232
+ const rect = gridEl.getBoundingClientRect();
233
+ const y = clientY - rect.top + gridEl.scrollTop;
234
+ return dayMs + (startHour + y / HOUR_HEIGHT) * HOUR_MS;
235
+ }
236
+ function clampToDay(ms) {
237
+ return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
238
+ }
239
+ function onGridPointerDown(e) {
240
+ if (e.button !== 0 || !drag || !oneventcreate || readOnly || isDisabled) return;
241
+ if (e.target.closest(".mb-event")) return;
242
+ mbCreateStartY = e.clientY;
243
+ mbCreateAnchorMs = gridTimeMs(e.clientY);
244
+ mbCreateStarted = false;
245
+ window.addEventListener("pointermove", onGridCreateMove);
246
+ window.addEventListener("pointerup", onGridCreateUp, { once: true });
247
+ window.addEventListener("pointercancel", onGridCreateCancel, { once: true });
248
+ }
249
+ function onGridCreateMove(e) {
250
+ if (!drag) return;
251
+ if (!mbCreateStarted) {
252
+ if (Math.abs(e.clientY - mbCreateStartY) < CREATE_THRESHOLD) return;
253
+ mbCreateStarted = true;
254
+ mbCreateAnchorMs = clampToDay(Math.floor(mbCreateAnchorMs / SNAP_MS) * SNAP_MS);
255
+ drag.beginCreate(new Date(mbCreateAnchorMs), new Date(mbCreateAnchorMs + SNAP_MS));
256
+ }
257
+ const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
258
+ drag.updatePointer(
259
+ new Date(Math.min(mbCreateAnchorMs, snapped)),
260
+ new Date(Math.max(mbCreateAnchorMs + SNAP_MS, snapped))
261
+ );
262
+ }
263
+ function cleanupGridCreate() {
264
+ window.removeEventListener("pointermove", onGridCreateMove);
265
+ window.removeEventListener("pointerup", onGridCreateUp);
266
+ window.removeEventListener("pointercancel", onGridCreateCancel);
267
+ mbCreateStarted = false;
268
+ }
269
+ function onGridCreateUp() {
270
+ if (drag && mbCreateStarted) {
271
+ suppressGridClick = true;
272
+ commitDragCtx?.();
273
+ setTimeout(() => {
274
+ suppressGridClick = false;
275
+ }, 0);
276
+ }
277
+ cleanupGridCreate();
278
+ }
279
+ function onGridCreateCancel() {
280
+ if (drag && mbCreateStarted) drag.cancel();
281
+ cleanupGridCreate();
282
+ }
283
+ let suppressEventClick = false;
284
+ let mbRsStartY = 0;
285
+ let mbRsStarted = false;
286
+ let mbRsEdge = "end";
287
+ let mbRsEvent = null;
288
+ function onResizePointerDown(e, ev, edge) {
289
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
290
+ e.stopPropagation();
291
+ mbRsStartY = e.clientY;
292
+ mbRsStarted = false;
293
+ mbRsEdge = edge;
294
+ mbRsEvent = ev;
295
+ window.addEventListener("pointermove", onResizeMove);
296
+ window.addEventListener("pointerup", onResizeUp, { once: true });
297
+ window.addEventListener("pointercancel", onResizeCancel, { once: true });
298
+ }
299
+ function onResizeMove(e) {
300
+ const ev = mbRsEvent;
301
+ if (!drag || !ev) return;
302
+ if (!mbRsStarted) {
303
+ if (Math.abs(e.clientY - mbRsStartY) < CREATE_THRESHOLD) return;
304
+ mbRsStarted = true;
305
+ drag.beginResize(ev.id, mbRsEdge, ev.start, ev.end);
306
+ }
307
+ const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
308
+ if (mbRsEdge === "end") {
309
+ const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
310
+ drag.updatePointer(ev.start, new Date(end));
311
+ } else {
312
+ const start = Math.min(snapped, ev.end.getTime() - SNAP_MS);
313
+ drag.updatePointer(new Date(start), ev.end);
314
+ }
315
+ }
316
+ function cleanupResize() {
317
+ window.removeEventListener("pointermove", onResizeMove);
318
+ window.removeEventListener("pointerup", onResizeUp);
319
+ window.removeEventListener("pointercancel", onResizeCancel);
320
+ mbRsStarted = false;
321
+ mbRsEvent = null;
322
+ }
323
+ function onResizeUp() {
324
+ if (drag && mbRsStarted) {
325
+ suppressEventClick = true;
326
+ suppressGridClick = true;
327
+ commitDragCtx?.();
328
+ setTimeout(() => {
329
+ suppressEventClick = false;
330
+ suppressGridClick = false;
331
+ }, 0);
332
+ }
333
+ cleanupResize();
334
+ }
335
+ function onResizeCancel() {
336
+ if (drag && mbRsStarted) drag.cancel();
337
+ cleanupResize();
338
+ }
339
+ let gridEl;
340
+ onMount(() => {
341
+ if (nowOffset > 0 && gridEl) {
342
+ const scrollTarget = Math.max(0, nowOffset - 120);
343
+ gridEl.scrollTop = scrollTarget;
344
+ }
345
+ });
273
346
  </script>
274
347
 
275
348
  <div
@@ -311,6 +384,7 @@
311
384
  class="mb-grid"
312
385
  bind:this={gridEl}
313
386
  onclick={handleGridClick}
387
+ onpointerdown={onGridPointerDown}
314
388
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleGridClick(e as unknown as MouseEvent); }}
315
389
  role="grid"
316
390
  tabindex="-1"
@@ -356,17 +430,19 @@
356
430
  class:mb-event--tentative={p.ev.status === 'tentative'}
357
431
  class:mb-event--full={p.ev.status === 'full'}
358
432
  class:mb-event--limited={p.ev.status === 'limited'}
433
+ class:mb-event--resizing={p.isResizing}
359
434
  style:top="{p.top}px"
360
435
  style:height="{p.height}px"
361
436
  style:left={p.left}
362
437
  style:width={p.width}
363
438
  style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
364
- onclick={(e) => { e.stopPropagation(); oneventclick?.(p.ev); }}
439
+ onclick={(e) => { e.stopPropagation(); if (suppressEventClick) { suppressEventClick = false; return; } oneventclick?.(p.ev); }}
365
440
  onpointerenter={() => oneventhover?.(p.ev)}
366
441
  aria-label="{p.ev.title}{p.ev.status === 'cancelled' ? ' (cancelled)' : ''}{p.ev.status === 'tentative' ? ' (tentative)' : ''}{p.ev.status === 'full' ? ' (full)' : ''}{p.ev.status === 'limited' ? ' (limited)' : ''}{p.isCurrent ? `, ${L.inProgress}` : ''}{p.isNext ? `, ${L.upNext}` : ''}"
367
442
  >
368
443
  <div class="mb-ev-stripe"></div>
369
444
  <div class="mb-ev-body">
445
+ <EventContent event={p.ev}>
370
446
  <span class="mb-ev-title">{p.ev.title}</span>
371
447
  {#if p.height > 32}
372
448
  <span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
@@ -384,14 +460,38 @@
384
460
  {/each}
385
461
  </div>
386
462
  {/if}
463
+ </EventContent>
387
464
  </div>
388
465
  {#if p.isCurrent}
389
466
  <span class="mb-ev-live"></span>
390
467
  {:else if p.isNext}
391
468
  <span class="mb-ev-next-badge">{L.upNext}</span>
392
469
  {/if}
470
+ {#if !readOnly && !p.ev.data?.readOnly}
471
+ <span
472
+ class="mb-ev-handle mb-ev-handle--start"
473
+ aria-hidden="true"
474
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
475
+ ></span>
476
+ <span
477
+ class="mb-ev-handle mb-ev-handle--end"
478
+ aria-hidden="true"
479
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
480
+ ></span>
481
+ {/if}
393
482
  </button>
394
483
  {/each}
484
+
485
+ <!-- Drag-to-create ghost -->
486
+ {#if !readOnly && drag?.active && drag.mode === 'create' && drag.payload}
487
+ {@const gTop = ((drag.payload.start.getTime() - dayMs) / HOUR_MS - startHour) * HOUR_HEIGHT}
488
+ {@const gH = Math.max(12, ((drag.payload.end.getTime() - drag.payload.start.getTime()) / HOUR_MS) * HOUR_HEIGHT)}
489
+ <div class="mb-create-ghost" style:top="{gTop}px" style:height="{gH}px" aria-hidden="true">
490
+ <span class="mb-create-ghost-time">
491
+ {fmtTime(drag.payload.start, locale)} – {fmtTime(drag.payload.end, locale)}
492
+ </span>
493
+ </div>
494
+ {/if}
395
495
  </div>
396
496
  </div>
397
497
  </div>
@@ -627,6 +727,62 @@
627
727
  opacity: 0.65;
628
728
  border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
629
729
  }
730
+ .mb-event--resizing {
731
+ z-index: 50;
732
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
733
+ cursor: ns-resize;
734
+ }
735
+
736
+ /* ─── Resize handles ─────────────────────────────── */
737
+ .mb-ev-handle {
738
+ position: absolute;
739
+ left: 0;
740
+ right: 0;
741
+ height: 10px;
742
+ z-index: 2;
743
+ cursor: ns-resize;
744
+ touch-action: none;
745
+ }
746
+ .mb-ev-handle--start { top: 0; }
747
+ .mb-ev-handle--end { bottom: 0; }
748
+ .mb-ev-handle::after {
749
+ content: '';
750
+ position: absolute;
751
+ left: 50%;
752
+ transform: translateX(-50%);
753
+ width: 24px;
754
+ height: 3px;
755
+ border-radius: 2px;
756
+ background: var(--ev-color);
757
+ opacity: 0;
758
+ transition: opacity 120ms;
759
+ }
760
+ .mb-ev-handle--start::after { top: 2px; }
761
+ .mb-ev-handle--end::after { bottom: 2px; }
762
+ .mb-event:hover .mb-ev-handle::after,
763
+ .mb-event--resizing .mb-ev-handle::after,
764
+ .mb-event--selected .mb-ev-handle::after { opacity: 0.55; }
765
+
766
+ /* ─── Drag-to-create ghost ───────────────────────── */
767
+ .mb-create-ghost {
768
+ position: absolute;
769
+ left: 40px;
770
+ right: 4px;
771
+ z-index: 40;
772
+ border-radius: 8px;
773
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 12%, transparent);
774
+ border: 1px dashed color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
775
+ display: flex;
776
+ align-items: flex-start;
777
+ overflow: hidden;
778
+ pointer-events: none;
779
+ }
780
+ .mb-create-ghost-time {
781
+ font: 600 10px/1 var(--dt-mono, ui-monospace, monospace);
782
+ color: var(--dt-accent, #2563eb);
783
+ padding: 4px 8px;
784
+ white-space: nowrap;
785
+ }
630
786
 
631
787
  .mb-ev-stripe {
632
788
  width: 4px;