@nomideusz/svelte-calendar 0.14.1 → 0.15.2

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.
@@ -12,18 +12,8 @@ import { createClock } from "../../core/clock.svelte.js";
12
12
  import { DAY_MS, HOUR_MS, sod, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
13
13
  import { fmtH, fmtTime } from "../../core/locale.js";
14
14
  import { createSwipe } from "./swipe.js";
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();
15
+ let { height = null, events = [], style = "", locale, focusDate, oneventclick, oneventcreate, selectedEventId = null, readOnly = false, visibleHours } = $props();
16
+ // ── Context ────────────────────────────────────────
27
17
  const ctx = useCalendarContext();
28
18
  const L = $derived(ctx.labels);
29
19
  const viewState = $derived(ctx.viewState);
@@ -37,189 +27,218 @@ const drag = $derived(ctx.drag);
37
27
  const commitDragCtx = $derived(ctx.commitDrag);
38
28
  const SNAP_MS = $derived(ctx.snapInterval * 6e4);
39
29
  const clock = createClock(ctx.timezone);
30
+ // ── Config ─────────────────────────────────────────
40
31
  const HOUR_HEIGHT = 64;
41
32
  const GUTTER_W = 40;
42
33
  const startHour = $derived(visibleHours?.[0] ?? 0);
43
34
  const endHour = $derived(visibleHours?.[1] ?? 24);
44
35
  const hourCount = $derived(Math.max(1, endHour - startHour));
45
36
  const gridHeight = $derived(hourCount * HOUR_HEIGHT);
37
+ // ── Day state ──────────────────────────────────────
46
38
  const dayMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
47
39
  const dayEnd = $derived(dayMs + DAY_MS);
48
40
  const isToday = $derived(dayMs === clock.today);
49
41
  const isDisabled = $derived(disabledSet.has(dayMs));
42
+ // ── Load range ─────────────────────────────────────
50
43
  $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);
44
+ if (!loadRangeCtx) return;
45
+ const rangeStart = new Date(dayMs - 2 * DAY_MS);
46
+ const rangeEnd = new Date(dayMs + 3 * DAY_MS);
47
+ loadRangeCtx.set({
48
+ start: rangeStart,
49
+ end: rangeEnd
50
+ });
51
+ return () => loadRangeCtx.set(null);
56
52
  });
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
- );
53
+ // ── Events partition ───────────────────────────────
54
+ const timedEvents = $derived(events.filter((ev) => !isAllDay(ev) && !isMultiDay(ev) && ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs).sort((a, b) => a.start.getTime() - b.start.getTime()));
60
55
  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;
56
+ const segs = [];
57
+ for (const ev of events) {
58
+ if (!isAllDay(ev) && !isMultiDay(ev)) continue;
59
+ const seg = segmentForDay(ev, dayMs);
60
+ if (seg) segs.push(seg);
61
+ }
62
+ return segs;
68
63
  });
69
64
  const isEmpty = $derived(timedEvents.length === 0 && allDayEvents.length === 0);
65
+ // ── All-day strip expansion ────────────────────────
70
66
  const ALLDAY_MAX = 3;
71
67
  let allDayExpanded = $state(false);
72
68
  const visibleAllDay = $derived(allDayExpanded ? allDayEvents : allDayEvents.slice(0, ALLDAY_MAX));
73
69
  const positionedEvents = $derived.by(() => {
74
- const now = clock.tick;
75
- const sorted = [...timedEvents];
76
- const rsP = drag?.active && (drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
77
- let nextEventId = null;
78
- if (isToday) {
79
- for (const ev of [...sorted].sort((a, b) => a.start.getTime() - b.start.getTime())) {
80
- const s = ev.start.getTime();
81
- if (s > now) {
82
- nextEventId = ev.id;
83
- break;
84
- }
85
- }
86
- }
87
- const infos = sorted.map((ev) => {
88
- const resizing = rsP?.eventId === ev.id;
89
- const evStart = resizing ? rsP.start : ev.start;
90
- const evEnd = resizing ? rsP.end : ev.end;
91
- const sMs = Math.max(evStart.getTime(), dayMs + startHour * HOUR_MS);
92
- const eMs = Math.min(evEnd.getTime(), dayMs + endHour * HOUR_MS);
93
- const topH = (sMs - dayMs) / HOUR_MS - startHour;
94
- const botH = (eMs - dayMs) / HOUR_MS - startHour;
95
- return {
96
- ev,
97
- top: topH * HOUR_HEIGHT,
98
- height: Math.max(24, (botH - topH) * HOUR_HEIGHT),
99
- isCurrent: ev.start.getTime() <= now && ev.end.getTime() > now,
100
- isNext: ev.id === nextEventId,
101
- isResizing: resizing,
102
- startMs: sMs,
103
- endMs: eMs,
104
- col: 0,
105
- totalCols: 1
106
- };
107
- });
108
- const par = infos.map((_, i) => i);
109
- function find(i) {
110
- while (par[i] !== i) {
111
- par[i] = par[par[i]];
112
- i = par[i];
113
- }
114
- return i;
115
- }
116
- for (let i = 0; i < infos.length; i++) {
117
- for (let j = i + 1; j < infos.length; j++) {
118
- if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
119
- else break;
120
- }
121
- }
122
- const groups = /* @__PURE__ */ new Map();
123
- for (let i = 0; i < infos.length; i++) {
124
- const root = find(i);
125
- if (!groups.has(root)) groups.set(root, []);
126
- groups.get(root).push(i);
127
- }
128
- for (const [, indices] of groups) {
129
- const rows = [];
130
- for (const idx of indices) {
131
- let row = 0;
132
- for (let r = 0; r < rows.length; r++) {
133
- if (rows[r] <= infos[idx].startMs) {
134
- row = r;
135
- rows[r] = infos[idx].endMs;
136
- break;
137
- }
138
- row = r + 1;
139
- }
140
- if (row >= rows.length) rows.push(infos[idx].endMs);
141
- infos[idx].col = row;
142
- }
143
- for (const idx of indices) infos[idx].totalCols = rows.length;
144
- }
145
- return infos.map((info) => ({
146
- ev: info.ev,
147
- top: info.top,
148
- height: info.height,
149
- left: `calc(${GUTTER_W}px + ${info.col / info.totalCols * 100}% - ${GUTTER_W * info.col / info.totalCols}px)`,
150
- width: `calc(${100 / info.totalCols}% - ${GUTTER_W / info.totalCols + 2}px)`,
151
- isCurrent: info.isCurrent,
152
- isNext: info.isNext,
153
- isResizing: info.isResizing,
154
- col: info.col,
155
- totalCols: info.totalCols
156
- }));
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
+ // Find the next upcoming event today
74
+ let nextEventId = null;
75
+ if (isToday) {
76
+ for (const ev of [...sorted].sort((a, b) => a.start.getTime() - b.start.getTime())) {
77
+ const s = ev.start.getTime();
78
+ if (s > now) {
79
+ nextEventId = ev.id;
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ // Overlap grouping
85
+ const infos = sorted.map((ev) => {
86
+ // While resizing, this event tracks the tentative drag payload
87
+ const resizing = rsP?.eventId === ev.id;
88
+ const evStart = resizing ? rsP.start : ev.start;
89
+ const evEnd = resizing ? rsP.end : ev.end;
90
+ const sMs = Math.max(evStart.getTime(), dayMs + startHour * HOUR_MS);
91
+ const eMs = Math.min(evEnd.getTime(), dayMs + endHour * HOUR_MS);
92
+ const topH = (sMs - dayMs) / HOUR_MS - startHour;
93
+ const botH = (eMs - dayMs) / HOUR_MS - startHour;
94
+ return {
95
+ ev,
96
+ top: topH * HOUR_HEIGHT,
97
+ height: Math.max(24, (botH - topH) * HOUR_HEIGHT),
98
+ isCurrent: ev.start.getTime() <= now && ev.end.getTime() > now,
99
+ isNext: ev.id === nextEventId,
100
+ isResizing: resizing,
101
+ startMs: sMs,
102
+ endMs: eMs,
103
+ col: 0,
104
+ totalCols: 1
105
+ };
106
+ });
107
+ // Assign columns for overlapping events
108
+ const par = infos.map((_, i) => i);
109
+ function find(i) {
110
+ while (par[i] !== i) {
111
+ par[i] = par[par[i]];
112
+ i = par[i];
113
+ }
114
+ return i;
115
+ }
116
+ for (let i = 0; i < infos.length; i++) {
117
+ for (let j = i + 1; j < infos.length; j++) {
118
+ if (infos[j].startMs < infos[i].endMs) par[find(i)] = find(j);
119
+ else break;
120
+ }
121
+ }
122
+ const groups = new Map();
123
+ for (let i = 0; i < infos.length; i++) {
124
+ const root = find(i);
125
+ if (!groups.has(root)) groups.set(root, []);
126
+ groups.get(root).push(i);
127
+ }
128
+ for (const [, indices] of groups) {
129
+ const rows = [];
130
+ for (const idx of indices) {
131
+ let row = 0;
132
+ for (let r = 0; r < rows.length; r++) {
133
+ if (rows[r] <= infos[idx].startMs) {
134
+ row = r;
135
+ rows[r] = infos[idx].endMs;
136
+ break;
137
+ }
138
+ row = r + 1;
139
+ }
140
+ if (row >= rows.length) rows.push(infos[idx].endMs);
141
+ infos[idx].col = row;
142
+ }
143
+ for (const idx of indices) infos[idx].totalCols = rows.length;
144
+ }
145
+ return infos.map((info) => ({
146
+ ev: info.ev,
147
+ top: info.top,
148
+ height: info.height,
149
+ left: `calc(${GUTTER_W}px + ${info.col / info.totalCols * 100}% - ${GUTTER_W * info.col / info.totalCols}px)`,
150
+ width: `calc(${100 / info.totalCols}% - ${GUTTER_W / info.totalCols + 2}px)`,
151
+ isCurrent: info.isCurrent,
152
+ isNext: info.isNext,
153
+ isResizing: info.isResizing,
154
+ col: info.col,
155
+ totalCols: info.totalCols
156
+ }));
157
157
  });
158
+ // ── Now indicator ──────────────────────────────────
158
159
  const nowOffset = $derived.by(() => {
159
- if (!isToday) return -1;
160
- const h = (clock.tick - dayMs) / HOUR_MS - startHour;
161
- if (h < 0 || h > hourCount) return -1;
162
- return h * HOUR_HEIGHT;
160
+ if (!isToday) return -1;
161
+ const h = (clock.tick - dayMs) / HOUR_MS - startHour;
162
+ if (h < 0 || h > hourCount) return -1;
163
+ return h * HOUR_HEIGHT;
163
164
  });
165
+ // ── Blocked slot check ─────────────────────────────
164
166
  function isBlockedAt(hour) {
165
- if (!blockedSlots?.length) return false;
166
- const jsDay = new Date(dayMs).getDay();
167
- const isoDay = jsDay === 0 ? 7 : jsDay;
168
- return blockedSlots.some((slot) => {
169
- if (slot.day && slot.day !== isoDay) return false;
170
- return hour >= slot.start && hour < slot.end;
171
- });
167
+ if (!blockedSlots?.length) return false;
168
+ const jsDay = new Date(dayMs).getDay();
169
+ const isoDay = jsDay === 0 ? 7 : jsDay;
170
+ return blockedSlots.some((slot) => {
171
+ if (slot.day && slot.day !== isoDay) return false;
172
+ return hour >= slot.start && hour < slot.end;
173
+ });
172
174
  }
175
+ // ── Status label (aria) ────────────────────────────
173
176
  function statusText(ev) {
174
- if (ev.status === "cancelled") return ` (${L.cancelled})`;
175
- if (ev.status === "tentative") return ` (${L.tentative})`;
176
- if (ev.status === "full") return ` (${L.full})`;
177
- if (ev.status === "limited") return ` (${L.limited})`;
178
- return "";
177
+ if (ev.status === "cancelled") return ` (${L.cancelled})`;
178
+ if (ev.status === "tentative") return ` (${L.tentative})`;
179
+ if (ev.status === "full") return ` (${L.full})`;
180
+ if (ev.status === "limited") return ` (${L.limited})`;
181
+ return "";
179
182
  }
183
+ // ── Touch swipe navigation ─────────────────────────
180
184
  let swipeOffset = $state(0);
181
185
  let swipeAnimate = $state(false);
182
186
  const swipe = createSwipe({
183
- disabled: () => !!drag?.active || mbCreateStarted || mbRsStarted || longPressTimer !== null,
184
- onmove: (dx) => {
185
- swipeAnimate = false;
186
- swipeOffset = dx;
187
- },
188
- onend: (dir) => {
189
- if (dir !== 0) {
190
- swipeAnimate = false;
191
- swipeOffset = 0;
192
- if (dir > 0) viewState?.prev();
193
- else viewState?.next();
194
- } else {
195
- swipeAnimate = true;
196
- swipeOffset = 0;
197
- }
198
- }
187
+ disabled: () => !!drag?.active || mbCreateStarted || mbRsStarted || longPressTimer !== null,
188
+ onmove: (dx) => {
189
+ swipeAnimate = false;
190
+ swipeOffset = dx;
191
+ },
192
+ onend: (dir) => {
193
+ if (dir !== 0) {
194
+ // Committed — the day changes, so jump rather than slide new content.
195
+ swipeAnimate = false;
196
+ swipeOffset = 0;
197
+ if (dir > 0) viewState?.prev();
198
+ else viewState?.next();
199
+ } else {
200
+ // Snap back (CSS drops the transition under prefers-reduced-motion).
201
+ swipeAnimate = true;
202
+ swipeOffset = 0;
203
+ }
204
+ }
199
205
  });
206
+ // ── Tap-to-create ──────────────────────────────────
200
207
  function handleGridClick(e) {
201
- if (suppressGridClick) {
202
- suppressGridClick = false;
203
- return;
204
- }
205
- if (!oneventcreate || readOnly || isDisabled) return;
206
- if (e.target.closest(".mb-event")) return;
207
- const tMs = gridTimeMs(e.clientY);
208
- if (isBlockedAt((tMs - dayMs) / HOUR_MS)) return;
209
- const startMs = clampToDay(Math.floor(tMs / SNAP_MS) * SNAP_MS);
210
- const durMin = minDuration ?? 60;
211
- oneventcreate({ start: new Date(startMs), end: new Date(startMs + durMin * 6e4) });
208
+ if (suppressGridClick) {
209
+ suppressGridClick = false;
210
+ return;
211
+ }
212
+ if (!oneventcreate || readOnly || isDisabled) return;
213
+ if (e.target.closest(".mb-event")) return;
214
+ const tMs = gridTimeMs(e.clientY);
215
+ if (isBlockedAt((tMs - dayMs) / HOUR_MS)) return;
216
+ const startMs = clampToDay(Math.floor(tMs / SNAP_MS) * SNAP_MS);
217
+ const durMin = minDuration ?? 60;
218
+ oneventcreate({
219
+ start: new Date(startMs),
220
+ end: new Date(startMs + durMin * 6e4)
221
+ });
212
222
  }
223
+ // Keyboard create-at-default: next snap boundary from now (today),
224
+ // otherwise the first visible hour.
213
225
  function onGridKeydown(e) {
214
- if (e.key !== "Enter" && e.key !== " ") return;
215
- if (!oneventcreate || readOnly || isDisabled) return;
216
- e.preventDefault();
217
- const raw = isToday ? clock.tick : dayMs + startHour * HOUR_MS;
218
- const startMs = clampToDay(Math.ceil(raw / SNAP_MS) * SNAP_MS);
219
- if (isBlockedAt((startMs - dayMs) / HOUR_MS)) return;
220
- const durMin = minDuration ?? 60;
221
- oneventcreate({ start: new Date(startMs), end: new Date(startMs + durMin * 6e4) });
226
+ if (e.key !== "Enter" && e.key !== " ") return;
227
+ if (!oneventcreate || readOnly || isDisabled) return;
228
+ e.preventDefault();
229
+ const raw = isToday ? clock.tick : dayMs + startHour * HOUR_MS;
230
+ const startMs = clampToDay(Math.ceil(raw / SNAP_MS) * SNAP_MS);
231
+ if (isBlockedAt((startMs - dayMs) / HOUR_MS)) return;
232
+ const durMin = minDuration ?? 60;
233
+ oneventcreate({
234
+ start: new Date(startMs),
235
+ end: new Date(startMs + durMin * 6e4)
236
+ });
222
237
  }
238
+ // ── Drag-to-create (mouse: sweep; touch: long-press, then sweep) ──
239
+ // Mouse movement below CREATE_THRESHOLD stays a plain tap (handleGridClick).
240
+ // On touch, a vertical drag must remain a scroll, so drag-create only arms
241
+ // after a ~350ms still hold (moving > 8px first cancels it).
223
242
  const CREATE_THRESHOLD = 4;
224
243
  const LONG_PRESS_MS = 350;
225
244
  const LONG_PRESS_TOLERANCE = 8;
@@ -229,162 +248,172 @@ let mbCreateStartY = 0;
229
248
  let mbCreateAnchorMs = 0;
230
249
  let mbCreateStarted = false;
231
250
  let longPressTimer = null;
251
+ // touch-action can't change mid-gesture, so once a touch drag-create/resize
252
+ // is live we block native scrolling with a non-passive touchmove listener.
232
253
  function blockTouchScroll(e) {
233
- e.preventDefault();
254
+ e.preventDefault();
234
255
  }
235
256
  function addTouchScrollBlock() {
236
- window.addEventListener("touchmove", blockTouchScroll, { passive: false });
257
+ window.addEventListener("touchmove", blockTouchScroll, { passive: false });
237
258
  }
238
259
  function removeTouchScrollBlock() {
239
- window.removeEventListener("touchmove", blockTouchScroll);
260
+ window.removeEventListener("touchmove", blockTouchScroll);
240
261
  }
241
262
  function clearLongPress() {
242
- if (longPressTimer !== null) {
243
- clearTimeout(longPressTimer);
244
- longPressTimer = null;
245
- }
263
+ if (longPressTimer !== null) {
264
+ clearTimeout(longPressTimer);
265
+ longPressTimer = null;
266
+ }
246
267
  }
268
+ /** Pointer Y → epoch ms within the day grid (accounts for scroll). */
247
269
  function gridTimeMs(clientY) {
248
- const rect = gridEl.getBoundingClientRect();
249
- const y = clientY - rect.top + gridEl.scrollTop;
250
- return dayMs + (startHour + y / HOUR_HEIGHT) * HOUR_MS;
270
+ const rect = gridEl.getBoundingClientRect();
271
+ const y = clientY - rect.top + gridEl.scrollTop;
272
+ return dayMs + (startHour + y / HOUR_HEIGHT) * HOUR_MS;
251
273
  }
274
+ /** Clamp a timestamp into the visible hour range of this day. */
252
275
  function clampToDay(ms) {
253
- return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
276
+ return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
254
277
  }
255
278
  function startGridCreate() {
256
- if (!drag) return;
257
- mbCreateStarted = true;
258
- mbCreateAnchorMs = clampToDay(Math.floor(mbCreateAnchorMs / SNAP_MS) * SNAP_MS);
259
- drag.beginCreate(new Date(mbCreateAnchorMs), new Date(mbCreateAnchorMs + SNAP_MS));
260
- addTouchScrollBlock();
279
+ if (!drag) return;
280
+ mbCreateStarted = true;
281
+ mbCreateAnchorMs = clampToDay(Math.floor(mbCreateAnchorMs / SNAP_MS) * SNAP_MS);
282
+ drag.beginCreate(new Date(mbCreateAnchorMs), new Date(mbCreateAnchorMs + SNAP_MS));
283
+ addTouchScrollBlock();
261
284
  }
262
285
  function onGridPointerDown(e) {
263
- if (e.button !== 0 || !drag || !oneventcreate || readOnly || isDisabled) return;
264
- if (e.target.closest(".mb-event")) return;
265
- mbCreateStartX = e.clientX;
266
- mbCreateStartY = e.clientY;
267
- mbCreateAnchorMs = gridTimeMs(e.clientY);
268
- mbCreateStarted = false;
269
- if (e.pointerType === "touch") {
270
- longPressTimer = setTimeout(() => {
271
- longPressTimer = null;
272
- startGridCreate();
273
- }, LONG_PRESS_MS);
274
- }
275
- window.addEventListener("pointermove", onGridCreateMove);
276
- window.addEventListener("pointerup", onGridCreateUp, { once: true });
277
- window.addEventListener("pointercancel", onGridCreateCancel, { once: true });
286
+ if (e.button !== 0 || !drag || !oneventcreate || readOnly || isDisabled) return;
287
+ if (e.target.closest(".mb-event")) return;
288
+ mbCreateStartX = e.clientX;
289
+ mbCreateStartY = e.clientY;
290
+ mbCreateAnchorMs = gridTimeMs(e.clientY);
291
+ mbCreateStarted = false;
292
+ if (e.pointerType === "touch") {
293
+ longPressTimer = setTimeout(() => {
294
+ longPressTimer = null;
295
+ startGridCreate();
296
+ }, LONG_PRESS_MS);
297
+ }
298
+ window.addEventListener("pointermove", onGridCreateMove);
299
+ window.addEventListener("pointerup", onGridCreateUp, { once: true });
300
+ window.addEventListener("pointercancel", onGridCreateCancel, { once: true });
278
301
  }
279
302
  function onGridCreateMove(e) {
280
- if (!drag) return;
281
- if (!mbCreateStarted) {
282
- if (longPressTimer !== null) {
283
- const moved = Math.hypot(e.clientX - mbCreateStartX, e.clientY - mbCreateStartY);
284
- if (moved > LONG_PRESS_TOLERANCE) cleanupGridCreate();
285
- return;
286
- }
287
- if (e.pointerType === "touch") return;
288
- if (Math.abs(e.clientY - mbCreateStartY) < CREATE_THRESHOLD) return;
289
- startGridCreate();
290
- }
291
- const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
292
- drag.updatePointer(
293
- new Date(Math.min(mbCreateAnchorMs, snapped)),
294
- new Date(Math.max(mbCreateAnchorMs + SNAP_MS, snapped))
295
- );
303
+ if (!drag) return;
304
+ if (!mbCreateStarted) {
305
+ if (longPressTimer !== null) {
306
+ // Finger moved before the hold completed → it's a scroll/swipe.
307
+ const moved = Math.hypot(e.clientX - mbCreateStartX, e.clientY - mbCreateStartY);
308
+ if (moved > LONG_PRESS_TOLERANCE) cleanupGridCreate();
309
+ return;
310
+ }
311
+ if (e.pointerType === "touch") return;
312
+ if (Math.abs(e.clientY - mbCreateStartY) < CREATE_THRESHOLD) return;
313
+ startGridCreate();
314
+ }
315
+ const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
316
+ drag.updatePointer(new Date(Math.min(mbCreateAnchorMs, snapped)), new Date(Math.max(mbCreateAnchorMs + SNAP_MS, snapped)));
296
317
  }
297
318
  function cleanupGridCreate() {
298
- clearLongPress();
299
- removeTouchScrollBlock();
300
- window.removeEventListener("pointermove", onGridCreateMove);
301
- window.removeEventListener("pointerup", onGridCreateUp);
302
- window.removeEventListener("pointercancel", onGridCreateCancel);
303
- mbCreateStarted = false;
319
+ clearLongPress();
320
+ removeTouchScrollBlock();
321
+ window.removeEventListener("pointermove", onGridCreateMove);
322
+ window.removeEventListener("pointerup", onGridCreateUp);
323
+ window.removeEventListener("pointercancel", onGridCreateCancel);
324
+ mbCreateStarted = false;
304
325
  }
305
326
  function onGridCreateUp() {
306
- if (drag && mbCreateStarted) {
307
- suppressGridClick = true;
308
- commitDragCtx?.();
309
- setTimeout(() => {
310
- suppressGridClick = false;
311
- }, 0);
312
- }
313
- cleanupGridCreate();
327
+ if (drag && mbCreateStarted) {
328
+ // The click event (if any) fires synchronously after pointerup
329
+ suppressGridClick = true;
330
+ commitDragCtx?.();
331
+ setTimeout(() => {
332
+ suppressGridClick = false;
333
+ }, 0);
334
+ }
335
+ cleanupGridCreate();
314
336
  }
315
337
  function onGridCreateCancel() {
316
- if (drag && mbCreateStarted) drag.cancel();
317
- cleanupGridCreate();
338
+ // Gesture was taken over by the browser — abandon the tentative create
339
+ if (drag && mbCreateStarted) drag.cancel();
340
+ cleanupGridCreate();
318
341
  }
319
342
  function onGridContextMenu(e) {
320
- if (mbCreateStarted || longPressTimer !== null) e.preventDefault();
343
+ // Long-press on touch fires contextmenu on some platforms — keep it
344
+ // from interrupting an armed or pending drag-create.
345
+ if (mbCreateStarted || longPressTimer !== null) e.preventDefault();
321
346
  }
347
+ // ── Event resize (top/bottom edge handles) ─────────
322
348
  let suppressEventClick = false;
323
349
  let mbRsStartY = 0;
324
350
  let mbRsStarted = false;
325
351
  let mbRsEdge = "end";
326
352
  let mbRsEvent = null;
327
353
  function onResizePointerDown(e, ev, edge) {
328
- if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
329
- e.stopPropagation();
330
- mbRsStartY = e.clientY;
331
- mbRsStarted = false;
332
- mbRsEdge = edge;
333
- mbRsEvent = ev;
334
- window.addEventListener("pointermove", onResizeMove);
335
- window.addEventListener("pointerup", onResizeUp, { once: true });
336
- window.addEventListener("pointercancel", onResizeCancel, { once: true });
354
+ if (e.button !== 0 || !drag || readOnly || ev.data?.readOnly) return;
355
+ e.stopPropagation();
356
+ mbRsStartY = e.clientY;
357
+ mbRsStarted = false;
358
+ mbRsEdge = edge;
359
+ mbRsEvent = ev;
360
+ window.addEventListener("pointermove", onResizeMove);
361
+ window.addEventListener("pointerup", onResizeUp, { once: true });
362
+ window.addEventListener("pointercancel", onResizeCancel, { once: true });
337
363
  }
338
364
  function onResizeMove(e) {
339
- const ev = mbRsEvent;
340
- if (!drag || !ev) return;
341
- if (!mbRsStarted) {
342
- if (Math.abs(e.clientY - mbRsStartY) < CREATE_THRESHOLD) return;
343
- mbRsStarted = true;
344
- drag.beginResize(ev.id, mbRsEdge, ev.start, ev.end);
345
- addTouchScrollBlock();
346
- }
347
- const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
348
- if (mbRsEdge === "end") {
349
- const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
350
- drag.updatePointer(ev.start, new Date(end));
351
- } else {
352
- const start = Math.min(snapped, ev.end.getTime() - SNAP_MS);
353
- drag.updatePointer(new Date(start), ev.end);
354
- }
365
+ const ev = mbRsEvent;
366
+ if (!drag || !ev) return;
367
+ if (!mbRsStarted) {
368
+ if (Math.abs(e.clientY - mbRsStartY) < CREATE_THRESHOLD) return;
369
+ mbRsStarted = true;
370
+ drag.beginResize(ev.id, mbRsEdge, ev.start, ev.end);
371
+ addTouchScrollBlock();
372
+ }
373
+ const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
374
+ if (mbRsEdge === "end") {
375
+ const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
376
+ drag.updatePointer(ev.start, new Date(end));
377
+ } else {
378
+ const start = Math.min(snapped, ev.end.getTime() - SNAP_MS);
379
+ drag.updatePointer(new Date(start), ev.end);
380
+ }
355
381
  }
356
382
  function cleanupResize() {
357
- removeTouchScrollBlock();
358
- window.removeEventListener("pointermove", onResizeMove);
359
- window.removeEventListener("pointerup", onResizeUp);
360
- window.removeEventListener("pointercancel", onResizeCancel);
361
- mbRsStarted = false;
362
- mbRsEvent = null;
383
+ removeTouchScrollBlock();
384
+ window.removeEventListener("pointermove", onResizeMove);
385
+ window.removeEventListener("pointerup", onResizeUp);
386
+ window.removeEventListener("pointercancel", onResizeCancel);
387
+ mbRsStarted = false;
388
+ mbRsEvent = null;
363
389
  }
364
390
  function onResizeUp() {
365
- if (drag && mbRsStarted) {
366
- suppressEventClick = true;
367
- suppressGridClick = true;
368
- commitDragCtx?.();
369
- setTimeout(() => {
370
- suppressEventClick = false;
371
- suppressGridClick = false;
372
- }, 0);
373
- }
374
- cleanupResize();
391
+ if (drag && mbRsStarted) {
392
+ // The synthesized click can land on the button OR (if the pointer
393
+ // ended off the block) on the grid — suppress both paths.
394
+ suppressEventClick = true;
395
+ suppressGridClick = true;
396
+ commitDragCtx?.();
397
+ setTimeout(() => {
398
+ suppressEventClick = false;
399
+ suppressGridClick = false;
400
+ }, 0);
401
+ }
402
+ cleanupResize();
375
403
  }
376
404
  function onResizeCancel() {
377
- if (drag && mbRsStarted) drag.cancel();
378
- cleanupResize();
405
+ if (drag && mbRsStarted) drag.cancel();
406
+ cleanupResize();
379
407
  }
408
+ // ── Auto-scroll to now ─────────────────────────────
380
409
  let gridEl;
381
410
  $effect(() => {
382
- void dayMs;
383
- const el = gridEl;
384
- if (!el) return;
385
- untrack(() => {
386
- if (nowOffset >= 0) el.scrollTop = Math.max(0, nowOffset - 120);
387
- });
411
+ void dayMs;
412
+ const el = gridEl;
413
+ if (!el) return;
414
+ untrack(() => {
415
+ if (nowOffset >= 0) el.scrollTop = Math.max(0, nowOffset - 120);
416
+ });
388
417
  });
389
418
  </script>
390
419
 
@@ -918,10 +947,16 @@ $effect(() => {
918
947
  }
919
948
 
920
949
  /* ─── Resize handles ─────────────────────────────── */
950
+ /* Resizing lives only on the centered grip column. The old full-width
951
+ edge bands (24px of inward slop each) covered short events entirely —
952
+ min block height is 24px, so any tap near an edge risked starting a
953
+ resize. The grip is persistently visible on touch (hover:none below),
954
+ so the smaller target stays discoverable; taps elsewhere open. */
921
955
  .mb-ev-handle {
922
956
  position: absolute;
923
- left: 0;
924
- right: 0;
957
+ left: 50%;
958
+ transform: translateX(-50%);
959
+ width: 56px;
925
960
  height: 10px;
926
961
  z-index: 2;
927
962
  cursor: ns-resize;
@@ -958,6 +993,8 @@ $effect(() => {
958
993
  .mb-event:focus-within .mb-ev-handle::after,
959
994
  .mb-event--resizing .mb-ev-handle::after,
960
995
  .mb-event--selected .mb-ev-handle::after { opacity: 0.55; }
996
+ /* Finger on the grip column itself: brighten so the hit zone reads */
997
+ .mb-ev-handle:hover::after { opacity: 0.9; }
961
998
  /* Touch devices have no hover — show the handles persistently. */
962
999
  @media (hover: none) {
963
1000
  .mb-ev-handle::after { opacity: 0.55; }