@nomideusz/svelte-calendar 0.21.0 → 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.
Files changed (69) hide show
  1. package/README.md +502 -269
  2. package/dist/adapters/composite.d.ts +12 -1
  3. package/dist/adapters/composite.js +109 -47
  4. package/dist/adapters/errors.d.ts +22 -0
  5. package/dist/adapters/errors.js +32 -0
  6. package/dist/adapters/index.d.ts +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/adapters/jmap.js +26 -17
  9. package/dist/adapters/mapped.d.ts +2 -2
  10. package/dist/adapters/mapped.js +18 -9
  11. package/dist/adapters/memory.d.ts +1 -1
  12. package/dist/adapters/memory.js +5 -3
  13. package/dist/adapters/recurring.d.ts +1 -1
  14. package/dist/adapters/recurring.js +37 -19
  15. package/dist/adapters/rest.d.ts +2 -2
  16. package/dist/adapters/rest.js +9 -4
  17. package/dist/calendar/Calendar.svelte +122 -78
  18. package/dist/calendar/Calendar.svelte.d.ts +69 -14
  19. package/dist/calendar/index.d.ts +2 -2
  20. package/dist/calendar/index.js +1 -1
  21. package/dist/core/clock.svelte.js +3 -2
  22. package/dist/core/locale.d.ts +11 -1
  23. package/dist/core/locale.js +12 -12
  24. package/dist/core/time.d.ts +9 -0
  25. package/dist/core/time.js +8 -0
  26. package/dist/core/timezone.d.ts +7 -3
  27. package/dist/core/timezone.js +10 -4
  28. package/dist/engine/event-store.svelte.js +93 -56
  29. package/dist/engine/view-state.svelte.d.ts +5 -1
  30. package/dist/engine/view-state.svelte.js +5 -3
  31. package/dist/headless/create-agenda.svelte.d.ts +8 -1
  32. package/dist/headless/create-agenda.svelte.js +32 -14
  33. package/dist/headless/create-calendar.svelte.js +53 -17
  34. package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
  35. package/dist/headless/create-range-agenda.svelte.js +33 -12
  36. package/dist/headless/index.d.ts +1 -0
  37. package/dist/index.d.ts +10 -7
  38. package/dist/index.js +8 -5
  39. package/dist/primitives/DayHeader.svelte +3 -2
  40. package/dist/primitives/DayHeader.svelte.d.ts +1 -1
  41. package/dist/primitives/EmptySlot.svelte +5 -2
  42. package/dist/primitives/EventBlock.svelte +21 -14
  43. package/dist/primitives/FloatingPanel.svelte +110 -15
  44. package/dist/primitives/FloatingPanel.svelte.d.ts +7 -5
  45. package/dist/primitives/NowIndicator.svelte +11 -4
  46. package/dist/primitives/TimeGutter.svelte +10 -6
  47. package/dist/primitives/index.d.ts +1 -0
  48. package/dist/text-fit.d.ts +8 -2
  49. package/dist/views/agenda/AgendaDay.svelte +41 -33
  50. package/dist/views/agenda/AgendaWeek.svelte +71 -34
  51. package/dist/views/index.d.ts +2 -0
  52. package/dist/views/index.js +2 -0
  53. package/dist/views/mobile/MobileDay.svelte +98 -41
  54. package/dist/views/mobile/MobileWeek.svelte +29 -13
  55. package/dist/views/mobile/swipe.js +6 -1
  56. package/dist/views/month/MonthGrid.svelte +29 -17
  57. package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
  58. package/dist/views/planner/PlannerScroll.svelte +183 -51
  59. package/dist/views/planner/PlannerWeek.svelte +148 -48
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +24 -9
  62. package/widget/widget.js +7983 -7688
  63. package/dist/assets/favicon.svg +0 -9
  64. package/dist/widget/CalendarWidget.svelte +0 -114
  65. package/dist/widget/CalendarWidget.svelte.d.ts +0 -37
  66. package/dist/widget/index.d.ts +0 -1
  67. package/dist/widget/index.js +0 -1
  68. package/dist/widget/widget.d.ts +0 -10
  69. package/dist/widget/widget.js +0 -96
@@ -9,7 +9,7 @@
9
9
  import { useCalendarContext } from "../shared/context.svelte.js";
10
10
  import EventContent from "../shared/EventContent.svelte";
11
11
  import { createClock } from "../../core/clock.svelte.js";
12
- import { DAY_MS, HOUR_MS, sod, addDaysMs, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
12
+ import { HOUR_MS, sod, addDaysMs, diffDays, isAllDay, segmentForDay } from "../../core/time.js";
13
13
  import { fmtH, fmtTime } from "../../core/locale.js";
14
14
  import { createSwipe } from "./swipe.js";
15
15
  let { height = null, events = [], style = "", locale, focusDate, oneventclick, oneventcreate, selectedEventId = null, readOnly = false, visibleHours } = $props();
@@ -27,6 +27,36 @@ const drag = $derived(ctx.drag);
27
27
  const commitDragCtx = $derived(ctx.commitDrag);
28
28
  const SNAP_MS = $derived(ctx.snapInterval * 6e4);
29
29
  const clock = createClock(ctx.timezone);
30
+ // ── Wall-clock time axis ───────────────────────────
31
+ // The grid is 24 wall-clock rows. A DST day is 23 or 25 hours long, so
32
+ // `dayMs + h * HOUR_MS` lands an hour off after the change: every
33
+ // hour ↔ instant conversion goes through the local calendar instead.
34
+ /** Fractional wall-clock hour `hour` on the day starting at `dayMs` → epoch ms. */
35
+ function atHour(day, hour) {
36
+ // MakeTime sums the fields in local time, so 24 → next midnight.
37
+ return new Date(day).setHours(0, 0, 0, Math.round(hour * HOUR_MS));
38
+ }
39
+ /** Epoch ms → fractional wall-clock hour counted from the day at `day`
40
+ * (24 = the next midnight; calendar days, not 24 h steps). */
41
+ function hourOf(ms, day) {
42
+ const d = new Date(ms);
43
+ return diffDays(ms, day) * 24 + d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600 + d.getMilliseconds() / HOUR_MS;
44
+ }
45
+ /** Snap a fractional hour to the snap interval in wall-clock minutes of
46
+ * the local day (not UTC multiples — those are off in +05:30 zones). */
47
+ function snapHour(hour, how) {
48
+ const step = Math.max(1, ctx.snapInterval);
49
+ const units = hour * 60 / step;
50
+ // Epsilon: 10:15 must not floor to 10:00 (or ceil to 10:30) through float noise.
51
+ const n = how === "floor" ? Math.floor(units + 1e-9) : how === "ceil" ? Math.ceil(units - 1e-9) : Math.round(units);
52
+ return n * step / 60;
53
+ }
54
+ /** Timed events at least a (wall-clock) day long, and all-day ones, sit in
55
+ * the all-day strip; a shorter overnight event is drawn in the grid,
56
+ * clipped to this day. */
57
+ function inAllDayStrip(ev) {
58
+ return isAllDay(ev) || ev.end.getTime() >= addDaysMs(ev.start.getTime(), 1);
59
+ }
30
60
  // ── Config ─────────────────────────────────────────
31
61
  const HOUR_HEIGHT = 64;
32
62
  const GUTTER_W = 40;
@@ -42,8 +72,8 @@ const isDisabled = $derived(disabledSet.has(dayMs));
42
72
  // ── Load range ─────────────────────────────────────
43
73
  $effect(() => {
44
74
  if (!loadRangeCtx) return;
45
- const rangeStart = new Date(dayMs - 2 * DAY_MS);
46
- const rangeEnd = new Date(dayMs + 3 * DAY_MS);
75
+ const rangeStart = new Date(addDaysMs(dayMs, -2));
76
+ const rangeEnd = new Date(addDaysMs(dayMs, 3));
47
77
  loadRangeCtx.set({
48
78
  start: rangeStart,
49
79
  end: rangeEnd
@@ -51,11 +81,11 @@ $effect(() => {
51
81
  return () => loadRangeCtx.set(null);
52
82
  });
53
83
  // ── 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()));
84
+ const timedEvents = $derived(events.filter((ev) => !inAllDayStrip(ev) && ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs).sort((a, b) => a.start.getTime() - b.start.getTime()));
55
85
  const allDayEvents = $derived.by(() => {
56
86
  const segs = [];
57
87
  for (const ev of events) {
58
- if (!isAllDay(ev) && !isMultiDay(ev)) continue;
88
+ if (!inAllDayStrip(ev)) continue;
59
89
  const seg = segmentForDay(ev, dayMs);
60
90
  if (seg) segs.push(seg);
61
91
  }
@@ -82,27 +112,38 @@ const positionedEvents = $derived.by(() => {
82
112
  }
83
113
  }
84
114
  // Overlap grouping
85
- const infos = sorted.map((ev) => {
115
+ const bandStart = atHour(dayMs, startHour);
116
+ const bandEnd = atHour(dayMs, endHour);
117
+ const infos = sorted.flatMap((ev) => {
86
118
  // While resizing, this event tracks the tentative drag payload
87
119
  const resizing = rsP?.eventId === ev.id;
88
120
  const evStart = resizing ? rsP.start : ev.start;
89
121
  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 {
122
+ const s0 = evStart.getTime();
123
+ const e0 = evEnd.getTime();
124
+ const sMs = Math.max(s0, bandStart);
125
+ const eMs = Math.min(e0, bandEnd);
126
+ // Outside the visible hours: nothing to draw (a zero-length marker
127
+ // counts only when it sits inside them).
128
+ if (eMs < sMs || eMs === sMs && (s0 !== e0 || s0 < bandStart || s0 >= bandEnd)) return [];
129
+ const topH = hourOf(sMs, dayMs) - startHour;
130
+ const botH = hourOf(eMs, dayMs) - startHour;
131
+ return [{
95
132
  ev,
96
133
  top: topH * HOUR_HEIGHT,
97
134
  height: Math.max(24, (botH - topH) * HOUR_HEIGHT),
98
135
  isCurrent: ev.start.getTime() <= now && ev.end.getTime() > now,
99
136
  isNext: ev.id === nextEventId,
100
137
  isResizing: resizing,
138
+ // Grips only on the edges that are the event's own — not where the
139
+ // day or the visible hours cut it.
140
+ hasStart: s0 >= Math.max(dayMs, bandStart),
141
+ hasEnd: e0 <= Math.min(dayEnd, bandEnd),
101
142
  startMs: sMs,
102
143
  endMs: eMs,
103
144
  col: 0,
104
145
  totalCols: 1
105
- };
146
+ }];
106
147
  });
107
148
  // Assign columns for overlapping events
108
149
  const par = infos.map((_, i) => i);
@@ -151,6 +192,8 @@ const positionedEvents = $derived.by(() => {
151
192
  isCurrent: info.isCurrent,
152
193
  isNext: info.isNext,
153
194
  isResizing: info.isResizing,
195
+ hasStart: info.hasStart,
196
+ hasEnd: info.hasEnd,
154
197
  col: info.col,
155
198
  totalCols: info.totalCols
156
199
  }));
@@ -158,7 +201,7 @@ const positionedEvents = $derived.by(() => {
158
201
  // ── Now indicator ──────────────────────────────────
159
202
  const nowOffset = $derived.by(() => {
160
203
  if (!isToday) return -1;
161
- const h = (clock.tick - dayMs) / HOUR_MS - startHour;
204
+ const h = hourOf(clock.tick, dayMs) - startHour;
162
205
  if (h < 0 || h > hourCount) return -1;
163
206
  return h * HOUR_HEIGHT;
164
207
  });
@@ -184,8 +227,14 @@ function statusText(ev) {
184
227
  let swipeOffset = $state(0);
185
228
  let swipeAnimate = $state(false);
186
229
  const swipe = createSwipe({
187
- disabled: () => !!drag?.active || mbCreateStarted || mbRsStarted || longPressTimer !== null,
230
+ // A pending long-press must not block the swipe: pointerdown arms it
231
+ // before touchstart, so the swipe would never track. A horizontal move
232
+ // past the long-press tolerance cancels the pending create instead.
233
+ disabled: () => !!drag?.active || mbCreateStarted || mbRsStarted,
188
234
  onmove: (dx) => {
235
+ if (longPressTimer !== null && !mbCreateStarted && Math.abs(dx) > LONG_PRESS_TOLERANCE) {
236
+ cleanupGridCreate();
237
+ }
189
238
  swipeAnimate = false;
190
239
  swipeOffset = dx;
191
240
  },
@@ -211,9 +260,9 @@ function handleGridClick(e) {
211
260
  }
212
261
  if (!oneventcreate || readOnly || isDisabled) return;
213
262
  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);
263
+ const hour = gridHour(e.clientY);
264
+ if (isBlockedAt(hour)) return;
265
+ const startMs = clampToDay(atHour(dayMs, snapHour(hour, "floor")));
217
266
  const durMin = minDuration ?? 60;
218
267
  oneventcreate({
219
268
  start: new Date(startMs),
@@ -226,9 +275,9 @@ function onGridKeydown(e) {
226
275
  if (e.key !== "Enter" && e.key !== " ") return;
227
276
  if (!oneventcreate || readOnly || isDisabled) return;
228
277
  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;
278
+ const rawHour = isToday ? hourOf(clock.tick, dayMs) : startHour;
279
+ const startMs = clampToDay(atHour(dayMs, snapHour(rawHour, "ceil")));
280
+ if (isBlockedAt(hourOf(startMs, dayMs))) return;
232
281
  const durMin = minDuration ?? 60;
233
282
  oneventcreate({
234
283
  start: new Date(startMs),
@@ -265,20 +314,23 @@ function clearLongPress() {
265
314
  longPressTimer = null;
266
315
  }
267
316
  }
268
- /** Pointer Y → epoch ms within the day grid (accounts for scroll). */
269
- function gridTimeMs(clientY) {
317
+ /** Pointer Y → fractional wall-clock hour of this day (accounts for scroll). */
318
+ function gridHour(clientY) {
270
319
  const rect = gridEl.getBoundingClientRect();
271
320
  const y = clientY - rect.top + gridEl.scrollTop;
272
- return dayMs + (startHour + y / HOUR_HEIGHT) * HOUR_MS;
321
+ return startHour + y / HOUR_HEIGHT;
322
+ }
323
+ /** Pointer Y → wall-clock time snapped to the interval, clamped to the band. */
324
+ function gridSnapped(clientY, how) {
325
+ return clampToDay(atHour(dayMs, snapHour(gridHour(clientY), how)));
273
326
  }
274
327
  /** Clamp a timestamp into the visible hour range of this day. */
275
328
  function clampToDay(ms) {
276
- return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
329
+ return Math.max(atHour(dayMs, startHour), Math.min(atHour(dayMs, endHour), ms));
277
330
  }
278
331
  function startGridCreate() {
279
332
  if (!drag) return;
280
333
  mbCreateStarted = true;
281
- mbCreateAnchorMs = clampToDay(Math.floor(mbCreateAnchorMs / SNAP_MS) * SNAP_MS);
282
334
  drag.beginCreate(new Date(mbCreateAnchorMs), new Date(mbCreateAnchorMs + SNAP_MS));
283
335
  addTouchScrollBlock();
284
336
  }
@@ -287,7 +339,7 @@ function onGridPointerDown(e) {
287
339
  if (e.target.closest(".mb-event")) return;
288
340
  mbCreateStartX = e.clientX;
289
341
  mbCreateStartY = e.clientY;
290
- mbCreateAnchorMs = gridTimeMs(e.clientY);
342
+ mbCreateAnchorMs = gridSnapped(e.clientY, "floor");
291
343
  mbCreateStarted = false;
292
344
  if (e.pointerType === "touch") {
293
345
  longPressTimer = setTimeout(() => {
@@ -312,7 +364,7 @@ function onGridCreateMove(e) {
312
364
  if (Math.abs(e.clientY - mbCreateStartY) < CREATE_THRESHOLD) return;
313
365
  startGridCreate();
314
366
  }
315
- const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
367
+ const snapped = gridSnapped(e.clientY, "round");
316
368
  drag.updatePointer(new Date(Math.min(mbCreateAnchorMs, snapped)), new Date(Math.max(mbCreateAnchorMs + SNAP_MS, snapped)));
317
369
  }
318
370
  function cleanupGridCreate() {
@@ -370,7 +422,7 @@ function onResizeMove(e) {
370
422
  drag.beginResize(ev.id, mbRsEdge, ev.start, ev.end);
371
423
  addTouchScrollBlock();
372
424
  }
373
- const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
425
+ const snapped = gridSnapped(e.clientY, "round");
374
426
  if (mbRsEdge === "end") {
375
427
  const end = Math.max(snapped, ev.start.getTime() + SNAP_MS);
376
428
  drag.updatePointer(ev.start, new Date(end));
@@ -562,24 +614,29 @@ $effect(() => {
562
614
  <span class="mb-ev-next-badge">{L.upNext}</span>
563
615
  {/if}
564
616
  {#if !readOnly && !p.ev.data?.readOnly}
565
- <span
566
- class="mb-ev-handle mb-ev-handle--start"
567
- aria-hidden="true"
568
- onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
569
- ></span>
570
- <span
571
- class="mb-ev-handle mb-ev-handle--end"
572
- aria-hidden="true"
573
- onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
574
- ></span>
617
+ {#if p.hasStart}
618
+ <span
619
+ class="mb-ev-handle mb-ev-handle--start"
620
+ aria-hidden="true"
621
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
622
+ ></span>
623
+ {/if}
624
+ {#if p.hasEnd}
625
+ <span
626
+ class="mb-ev-handle mb-ev-handle--end"
627
+ aria-hidden="true"
628
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
629
+ ></span>
630
+ {/if}
575
631
  {/if}
576
632
  </button>
577
633
  {/each}
578
634
 
579
635
  <!-- Drag-to-create ghost -->
580
636
  {#if !readOnly && drag?.active && drag.mode === 'create' && drag.payload}
581
- {@const gTop = ((drag.payload.start.getTime() - dayMs) / HOUR_MS - startHour) * HOUR_HEIGHT}
582
- {@const gH = Math.max(12, ((drag.payload.end.getTime() - drag.payload.start.getTime()) / HOUR_MS) * HOUR_HEIGHT)}
637
+ {@const gTopH = hourOf(drag.payload.start.getTime(), dayMs)}
638
+ {@const gTop = (gTopH - startHour) * HOUR_HEIGHT}
639
+ {@const gH = Math.max(12, (hourOf(drag.payload.end.getTime(), dayMs) - gTopH) * HOUR_HEIGHT)}
583
640
  <div class="mb-create-ghost" style:top="{gTop}px" style:height="{gH}px" aria-hidden="true">
584
641
  <span class="mb-create-ghost-time">
585
642
  {fmtTime(drag.payload.start, locale)} – {fmtTime(drag.payload.end, locale)}
@@ -613,7 +670,7 @@ $effect(() => {
613
670
  overflow: hidden;
614
671
  background: var(--dt-bg, #fff);
615
672
  -webkit-tap-highlight-color: transparent;
616
- touch-action: pan-y;
673
+ touch-action: pan-y pinch-zoom;
617
674
  }
618
675
  .mb--auto { overflow: visible; }
619
676
 
@@ -8,7 +8,7 @@
8
8
  <script lang="ts">import { useCalendarContext } from "../shared/context.svelte.js";
9
9
  import EventContent from "../shared/EventContent.svelte";
10
10
  import { createClock } from "../../core/clock.svelte.js";
11
- import { DAY_MS, sod, addDaysMs, isAllDay, isMultiDay } from "../../core/time.js";
11
+ import { sod, addDaysMs, isAllDay } from "../../core/time.js";
12
12
  import { startOfWeek as sowFn } from "../../core/time.js";
13
13
  import { fmtTime as _fmtTime, weekdayShort } from "../../core/locale.js";
14
14
  import { createSwipe } from "./swipe.js";
@@ -35,8 +35,8 @@ const weekStart = $derived(customDays === 7 ? sowFn(focusMs, mondayStart) : sod(
35
35
  // ── Load range ─────────────────────────────────────
36
36
  $effect(() => {
37
37
  if (!loadRangeCtx) return;
38
- const rangeStart = new Date(weekStart - 7 * DAY_MS);
39
- const rangeEnd = new Date(weekStart + (customDays + 7) * DAY_MS);
38
+ const rangeStart = new Date(addDaysMs(weekStart, -7));
39
+ const rangeEnd = new Date(addDaysMs(weekStart, customDays + 7));
40
40
  loadRangeCtx.set({
41
41
  start: rangeStart,
42
42
  end: rangeEnd
@@ -58,7 +58,7 @@ const dayCells = $derived.by(() => {
58
58
  const isDisabled = disabledSet.has(ms);
59
59
  const dayEnd = addDaysMs(ms, 1);
60
60
  const dayEvents = events.filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > ms).sort((a, b) => a.start.getTime() - b.start.getTime());
61
- const allDayCount = dayEvents.filter((ev) => isAllDay(ev) || isMultiDay(ev)).length;
61
+ const allDayCount = dayEvents.filter(inAllDayStrip).length;
62
62
  result.push({
63
63
  ms,
64
64
  dayNum: d.getDate(),
@@ -86,10 +86,22 @@ function toggleExpand(ms) {
86
86
  function fmtTime(d) {
87
87
  return _fmtTime(d, locale);
88
88
  }
89
+ /** All-day events and timed ones at least a (wall-clock) day long read as
90
+ * "All day"; a shorter overnight event keeps its time on both days. */
91
+ function inAllDayStrip(ev) {
92
+ return isAllDay(ev) || ev.end.getTime() >= addDaysMs(ev.start.getTime(), 1);
93
+ }
89
94
  function evTimeLabel(ev) {
90
- if (isAllDay(ev) || isMultiDay(ev)) return L.allDay;
95
+ if (inAllDayStrip(ev)) return L.allDay;
91
96
  return `${fmtTime(ev.start)} – ${fmtTime(ev.end)}`;
92
97
  }
98
+ /** Visible time on a day row: the start, or "until 01:00" on the day an
99
+ * overnight event runs into. */
100
+ function evRowTime(ev, dayMs) {
101
+ if (inAllDayStrip(ev)) return L.allDay;
102
+ if (ev.start.getTime() < dayMs) return `${L.until} ${fmtTime(ev.end)}`;
103
+ return fmtTime(ev.start);
104
+ }
93
105
  function statusText(ev) {
94
106
  if (ev.status === "cancelled") return ` (${L.cancelled})`;
95
107
  if (ev.status === "tentative") return ` (${L.tentative})`;
@@ -98,6 +110,13 @@ function statusText(ev) {
98
110
  return "";
99
111
  }
100
112
  // ── Touch swipe ────────────────────────────────────
113
+ let listEl = $state(null);
114
+ // A new week starts at its first day: the list's scroll offset belongs to
115
+ // the week that was showing (swipe, header nav, or a host date change).
116
+ $effect(() => {
117
+ void weekStart;
118
+ if (listEl) listEl.scrollTop = 0;
119
+ });
101
120
  let swipeOffset = $state(0);
102
121
  let swipeAnimate = $state(false);
103
122
  const swipe = createSwipe({
@@ -155,6 +174,7 @@ function handleDayKeydown(e, dayMs) {
155
174
  <!-- Vertical day list -->
156
175
  <div
157
176
  class="mw-list"
177
+ bind:this={listEl}
158
178
  class:mw-list--animate={swipeAnimate}
159
179
  style:transform={swipeOffset !== 0 ? `translateX(${swipeOffset}px)` : undefined}
160
180
  role="list"
@@ -195,8 +215,8 @@ function handleDayKeydown(e, dayMs) {
195
215
  type="button"
196
216
  class="mw-ev"
197
217
  class:mw-ev--selected={selectedEventId === ev.id}
198
- class:mw-ev--allday={isAllDay(ev) || isMultiDay(ev)}
199
- class:mw-ev--current={!isAllDay(ev) && !isMultiDay(ev) && ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
218
+ class:mw-ev--allday={inAllDayStrip(ev)}
219
+ class:mw-ev--current={!inAllDayStrip(ev) && ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
200
220
  class:mw-ev--cancelled={ev.status === 'cancelled'}
201
221
  class:mw-ev--tentative={ev.status === 'tentative'}
202
222
  class:mw-ev--full={ev.status === 'full'}
@@ -211,11 +231,7 @@ function handleDayKeydown(e, dayMs) {
211
231
  <div class="mw-ev-body">
212
232
  <EventContent event={ev}>
213
233
  <span class="mw-ev-title">{ev.title}</span>
214
- {#if isAllDay(ev) || isMultiDay(ev)}
215
- <span class="mw-ev-time">{L.allDay}</span>
216
- {:else}
217
- <span class="mw-ev-time">{fmtTime(ev.start)}</span>
218
- {/if}
234
+ <span class="mw-ev-time">{evRowTime(ev, cell.ms)}</span>
219
235
  </EventContent>
220
236
  </div>
221
237
  </button>
@@ -251,7 +267,7 @@ function handleDayKeydown(e, dayMs) {
251
267
  overflow: hidden;
252
268
  background: var(--dt-bg, #fff);
253
269
  -webkit-tap-highlight-color: transparent;
254
- touch-action: pan-y;
270
+ touch-action: pan-y pinch-zoom;
255
271
  }
256
272
  .mw--auto { overflow: visible; }
257
273
 
@@ -22,6 +22,11 @@ export function createSwipe(cb) {
22
22
  }
23
23
  return {
24
24
  ontouchstart(e) {
25
+ // A second finger (pinch-zoom) is never a swipe.
26
+ if (e.touches.length > 1) {
27
+ abandon();
28
+ return;
29
+ }
25
30
  if (cb.disabled?.()) {
26
31
  tracking = false;
27
32
  return;
@@ -35,7 +40,7 @@ export function createSwipe(cb) {
35
40
  ontouchmove(e) {
36
41
  if (!tracking)
37
42
  return;
38
- if (cb.disabled?.()) {
43
+ if (e.touches.length > 1 || cb.disabled?.()) {
39
44
  abandon();
40
45
  return;
41
46
  }
@@ -44,23 +44,41 @@ let expandedMs = $state(null);
44
44
  let focusMs = $state(null);
45
45
  let bodyEl = $state(null);
46
46
  const cellsInteractive = $derived(!!ondayclick);
47
+ /** A cell takes focus when it can act: drill down, or open its overflow. */
48
+ function isFocusable(cell) {
49
+ return !cell.isDisabled && (cellsInteractive || cell.overflow > 0);
50
+ }
51
+ /** Focusable cells by day, in grid order. */
52
+ const focusable = $derived.by(() => {
53
+ const map = new Map();
54
+ for (const row of weeks) for (const cell of row) if (isFocusable(cell)) map.set(cell.ms, cell);
55
+ return map;
56
+ });
57
+ // The roving anchor always lands on a focusable cell, so the grid keeps
58
+ // its one Tab stop even when the remembered day or today is disabled.
47
59
  const rovingMs = $derived.by(() => {
48
- if (!range) return null;
49
- const start = sod(range.start.getTime());
50
- const end = range.end.getTime();
51
- if (focusMs !== null && focusMs >= start && focusMs < end) return focusMs;
52
- if (todayMs >= start && todayMs < end) return todayMs;
53
- return start;
60
+ if (focusMs !== null && focusable.has(focusMs)) return focusMs;
61
+ if (focusable.has(todayMs)) return todayMs;
62
+ const first = focusable.keys().next();
63
+ return first.done ? null : first.value;
54
64
  });
55
65
  function moveFocus(fromMs, deltaDays) {
56
66
  if (!range) return;
57
- const target = addDaysMs(fromMs, deltaDays);
58
- if (target < sod(range.start.getTime()) || target >= range.end.getTime()) return;
67
+ const lo = sod(range.start.getTime());
68
+ const hi = range.end.getTime();
69
+ // Skip days that can't take focus, continuing in the same direction.
70
+ let target = addDaysMs(fromMs, deltaDays);
71
+ while (target >= lo && target < hi && !focusable.has(target)) target = addDaysMs(target, deltaDays);
72
+ if (target < lo || target >= hi) return;
59
73
  focusMs = target;
60
74
  const el = bodyEl?.querySelector(`[data-ms="${target}"]`);
61
75
  el?.focus();
62
76
  }
63
77
  function cellKeydown(e, cell) {
78
+ // Enter/Space on an event chip or "+N more" belong to that button (a
79
+ // chip opens its event, not the day); arrows still move between days.
80
+ const onCell = e.target === e.currentTarget;
81
+ if (!onCell && (e.key === "Enter" || e.key === " ")) return;
64
82
  switch (e.key) {
65
83
  case "ArrowRight":
66
84
  e.preventDefault();
@@ -183,13 +201,7 @@ function chipTime(e) {
183
201
  aria-label={cellLabel(cell)}
184
202
  aria-current={cell.isToday ? 'date' : undefined}
185
203
  data-ms={cell.ms}
186
- tabindex={cell.isDisabled
187
- ? undefined
188
- : cellsInteractive || cell.overflow > 0
189
- ? cell.ms === rovingMs
190
- ? 0
191
- : -1
192
- : undefined}
204
+ tabindex={isFocusable(cell) ? (cell.ms === rovingMs ? 0 : -1) : undefined}
193
205
  onclick={() => {
194
206
  if (!cell.isDisabled) ondayclick?.(cell.date);
195
207
  }}
@@ -208,7 +220,7 @@ function chipTime(e) {
208
220
  class:mg-chip--selected={ev.id === selectedEventId}
209
221
  onclick={(e) => {
210
222
  e.stopPropagation();
211
- oneventclick?.(ev);
223
+ oneventclick?.(ev, e.currentTarget.getBoundingClientRect());
212
224
  }}
213
225
  onmouseenter={() => oneventhover?.(ev)}
214
226
  >
@@ -225,7 +237,7 @@ function chipTime(e) {
225
237
  aria-label="{ev.title}{chipTime(ev) ? `, ${chipTime(ev)}` : ''}"
226
238
  onclick={(e) => {
227
239
  e.stopPropagation();
228
- oneventclick?.(ev);
240
+ oneventclick?.(ev, e.currentTarget.getBoundingClientRect());
229
241
  }}
230
242
  onmouseenter={() => oneventhover?.(ev)}
231
243
  >
@@ -5,7 +5,7 @@ interface Props {
5
5
  height?: number | null;
6
6
  locale?: string;
7
7
  focusDate?: Date;
8
- oneventclick?: (event: TimelineEvent) => void;
8
+ oneventclick?: (event: TimelineEvent, anchor?: DOMRect) => void;
9
9
  selectedEventId?: string | null;
10
10
  readOnly?: boolean;
11
11
  [key: string]: unknown;