@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
@@ -20,13 +20,14 @@ import { prefersReducedMotion } from "svelte/motion";
20
20
  import { useCalendarContext } from "../shared/context.svelte.js";
21
21
  import EventContent from "../shared/EventContent.svelte";
22
22
  import { createClock } from "../../core/clock.svelte.js";
23
- import { DAY_MS, HOUR_MS, sod, addDaysMs } from "../../core/time.js";
24
- import { startOfWeek as sowFn, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
25
- import { weekdayShort, monthLong, fmtTime as _fmtTime, getLabels } from "../../core/locale.js";
23
+ import { HOUR_MS, sod, addDaysMs, diffDays } from "../../core/time.js";
24
+ import { startOfWeek as sowFn, isAllDay, segmentForDay } from "../../core/time.js";
25
+ import { weekdayShort, monthLong, fmtTime as _fmtTime } from "../../core/locale.js";
26
26
  import { createChipFit } from "../shared/chip-fit.svelte.js";
27
- const L = $derived(getLabels());
28
27
  let { mondayStart = true, locale, height = 520, events = [], style = "", focusDate, oneventclick, oneventcreate, onexternaldrop, selectedEventId = null, readOnly = false, visibleHours } = $props();
29
28
  const ctx = useCalendarContext();
29
+ /** Per-instance labels (Calendar's `labels` prop over the globals). */
30
+ const L = $derived(ctx.labels);
30
31
  const clock = createClock(ctx.timezone);
31
32
  // Drag ghost flies between day cells instead of teleporting.
32
33
  // No fallback: without a counterpart (drag start/end) it appears/disappears instantly.
@@ -72,6 +73,32 @@ function rowPitch() {
72
73
  const rows = el?.querySelectorAll("[data-week]");
73
74
  return rows && rows.length > 1 ? rows[1].offsetTop - rows[0].offsetTop : TIME_H + ROW_MARGIN;
74
75
  }
76
+ // ─── Wall-clock helpers ─────────────────────────
77
+ // The axis is wall-clock hours. A DST day is 23 or 25 hours long, so
78
+ // `dayMs + h * HOUR_MS` is an hour off after the change: convert through
79
+ // the local calendar instead.
80
+ /** Fractional wall-clock hour `hour` on the day starting at `dayMs` → epoch ms. */
81
+ function atHour(dayMs, hour) {
82
+ // MakeTime sums the fields in local time, so this may pass 24h.
83
+ return new Date(dayMs).setHours(0, 0, 0, Math.round(hour * HOUR_MS));
84
+ }
85
+ /** Epoch ms → fractional wall-clock hour counted from the day at `dayMs`. */
86
+ function hourOf(ms, dayMs) {
87
+ const d = new Date(ms);
88
+ return diffDays(ms, dayMs) * 24 + d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600 + d.getMilliseconds() / HOUR_MS;
89
+ }
90
+ /** All-day events and timed ones a (wall-clock) day or longer are bars;
91
+ * a shorter overnight event is a chip on the day it starts. */
92
+ function inAllDayStrip(ev) {
93
+ return isAllDay(ev) || ev.end.getTime() >= addDaysMs(ev.start.getTime(), 1);
94
+ }
95
+ function statusText(ev) {
96
+ if (ev.status === "cancelled") return ` (${L.cancelled})`;
97
+ if (ev.status === "tentative") return ` (${L.tentative})`;
98
+ if (ev.status === "full") return ` (${L.full})`;
99
+ if (ev.status === "limited") return ` (${L.limited})`;
100
+ return "";
101
+ }
75
102
  // ─── Time axis ──────────────────────────────────
76
103
  // Without visibleHours the axis spans the hours the loaded events start in:
77
104
  // a timetable of 7:00–21:00 classes spread over 24 hours bunches them up.
@@ -79,7 +106,7 @@ const eventHours = $derived.by(() => {
79
106
  let lo = 24;
80
107
  let hi = -1;
81
108
  for (const ev of events) {
82
- if (isAllDay(ev) || isMultiDay(ev)) continue;
109
+ if (inAllDayStrip(ev)) continue;
83
110
  const h = ev.start.getHours() + ev.start.getMinutes() / 60;
84
111
  lo = Math.min(lo, h);
85
112
  hi = Math.max(hi, h);
@@ -88,10 +115,10 @@ const eventHours = $derived.by(() => {
88
115
  });
89
116
  const startHour = $derived(visibleHours?.[0] ?? eventHours[0]);
90
117
  const endHour = $derived(visibleHours?.[1] ?? eventHours[1]);
91
- /** Where a chip starting this far into the day sits, px from the axis top.
92
- * A chip at the last hour still ends inside the floor height. */
93
- function timeTop(msIntoDay) {
94
- const f = (msIntoDay / HOUR_MS - startHour) / Math.max(1, endHour - startHour);
118
+ /** Where a chip starting this many wall-clock hours into the day sits, px
119
+ * from the axis top. A chip at the last hour still ends inside the floor height. */
120
+ function timeTop(hoursIntoDay) {
121
+ const f = (hoursIntoDay - startHour) / Math.max(1, endHour - startHour);
95
122
  return Math.min(Math.max(f, 0), 1) * (TIME_H - CHIP_H);
96
123
  }
97
124
  /** Each chip at its start time, or just under the chip before it when that
@@ -100,7 +127,7 @@ function chipTops(list, dayMs) {
100
127
  const tops = [];
101
128
  let min = 0;
102
129
  for (const ev of list) {
103
- const top = Math.max(timeTop(ev.start.getTime() - dayMs), min);
130
+ const top = Math.max(timeTop(hourOf(ev.start.getTime(), dayMs)), min);
104
131
  tops.push(top);
105
132
  min = top + CHIP_H + CHIP_GAP;
106
133
  }
@@ -110,7 +137,7 @@ function chipTops(list, dayMs) {
110
137
  * it), but never above a chip that has already started — pushed-down
111
138
  * chips would read as still to come. */
112
139
  function nowTop(list, tops, dayMs) {
113
- let y = timeTop(clock.tick - dayMs);
140
+ let y = timeTop(hourOf(clock.tick, dayMs));
114
141
  list.forEach((ev, i) => {
115
142
  if (ev.start.getTime() <= clock.tick) y = Math.max(y, tops[i] + CHIP_H + CHIP_GAP / 2);
116
143
  });
@@ -177,15 +204,17 @@ const weeks = $derived.by(() => {
177
204
  const isToday = ms === todayMs;
178
205
  const isPast = equalDays ? false : ms < todayMs;
179
206
  const dayEnd = addDaysMs(ms, 1);
180
- const dayEventsAll = events.filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > ms).sort((a, b) => a.start.getTime() - b.start.getTime());
181
- // Separate all-day / multi-day from timed events
207
+ // A zero-length event counts where it sits (even at midnight)
208
+ const dayEventsAll = events.filter((ev) => ev.start.getTime() < dayEnd && (ev.end.getTime() > ms || ev.start.getTime() >= ms)).sort((a, b) => a.start.getTime() - b.start.getTime());
209
+ // Separate all-day / day-long from timed events. A timed event that
210
+ // runs past midnight (22:00–01:00) is a chip on its start day only.
182
211
  const timedEvents = [];
183
212
  const allDaySegments = [];
184
213
  for (const ev of dayEventsAll) {
185
- if (isAllDay(ev) || isMultiDay(ev)) {
214
+ if (inAllDayStrip(ev)) {
186
215
  const seg = segmentForDay(ev, ms);
187
216
  if (seg) allDaySegments.push(seg);
188
- } else {
217
+ } else if (ev.start.getTime() >= ms && ev.end.getTime() >= ev.start.getTime()) {
189
218
  timedEvents.push(ev);
190
219
  }
191
220
  }
@@ -246,7 +275,15 @@ function fmtAmPm(d) {
246
275
  // ─── Scroll to current week on mount ────────────────
247
276
  onMount(() => {
248
277
  tick().then(() => scrollWeekIntoContainer());
249
- return () => cancelAnimationFrame(syncRaf);
278
+ return () => {
279
+ cancelAnimationFrame(syncRaf);
280
+ // Unmounted mid-gesture (a view switch between pointerdown and
281
+ // pointerup): drop the window listeners and the auto-scroll frame,
282
+ // and the half-made drag with them.
283
+ const live = evDragStarted;
284
+ cleanupEvDrag();
285
+ if (live && drag?.active) drag.cancel();
286
+ };
250
287
  });
251
288
  // Rows grow when their events arrive, which moves every row below them —
252
289
  // including the one we centred on an empty grid. Centre once more when the
@@ -347,31 +384,47 @@ function handleUserScroll() {
347
384
  }
348
385
  }
349
386
  }
350
- /** Where a new thing lands on this day, or null when the day refuses it.
351
- * A cell is a whole day here — there is no time axis to drop onto — so
352
- * everything starts at the first visible hour. One rule, so an empty-cell
353
- * click and a drop from outside can never disagree about a day. */
354
- function dayDropStart(ms) {
387
+ /** The time at a pointer's height on a day's axis — timeTop read backwards,
388
+ * the pointer at the chip's middle. Half hours: the whole day is ~100px.
389
+ * Wall-clock ms into the local day; dayDropStart makes it an instant. */
390
+ function pointerTime(cell, clientY) {
391
+ const area = cell.querySelector(".wg-cell-events");
392
+ if (!area) return startHour * HOUR_MS;
393
+ // The result is wall-clock time into the day (ms), rounded to half hours
394
+ // of the local day — dayDropStart turns it into an instant.
395
+ const y = clientY - area.getBoundingClientRect().top - CHIP_H / 2;
396
+ const f = Math.min(Math.max(y / (TIME_H - CHIP_H), 0), 1);
397
+ const hours = startHour + f * Math.max(1, endHour - startHour);
398
+ return Math.round(hours * 2) * (HOUR_MS / 2);
399
+ }
400
+ /** Where a new thing lands on this day, or null when the day (or that hour
401
+ * of it) refuses it. `at` is the pointer's time into the day; without one
402
+ * (the keyboard) it is the first hour of the axis. One rule, so an
403
+ * empty-cell click and a drop from outside can never disagree. */
404
+ function dayDropStart(ms, at = startHour * HOUR_MS) {
355
405
  if (disabledSet.has(ms)) return null;
356
- const startHour = visibleHours?.[0] ?? 9;
406
+ const hour = at / HOUR_MS;
357
407
  if (blockedSlots?.length) {
358
408
  const jsDay = new Date(ms).getDay();
359
409
  const isoDay = jsDay === 0 ? 7 : jsDay;
360
410
  const blocked = blockedSlots.some((slot) => {
361
411
  if (slot.day && slot.day !== isoDay) return false;
362
- return startHour >= slot.start && startHour < slot.end;
412
+ return hour >= slot.start && hour < slot.end;
363
413
  });
364
414
  if (blocked) return null;
365
415
  }
366
- return new Date(ms + startHour * HOUR_MS);
416
+ return new Date(atHour(ms, hour));
367
417
  }
368
418
  function handleDayCellClick(ms, e) {
369
419
  const target = e.target;
370
420
  if (target.closest(".wg-ev, .wg-ad, .wg-ev-more")) return;
371
421
  if (readOnly || !oneventcreate) return;
372
- const start = dayDropStart(ms);
422
+ const at = e instanceof MouseEvent ? pointerTime(e.currentTarget, e.clientY) : undefined;
423
+ const start = dayDropStart(ms, at);
373
424
  if (!start) return;
374
- const durMin = minDuration ? Math.max(60, minDuration) : 60;
425
+ // An hour, or the minimum duration when that is longer. Blocked-slot and
426
+ // max-duration validation is Calendar's (ctx.oneventcreate), not ours.
427
+ const durMin = Math.max(60, minDuration ?? 0);
375
428
  oneventcreate({
376
429
  start,
377
430
  end: new Date(start.getTime() + durMin * 6e4)
@@ -379,21 +432,31 @@ function handleDayCellClick(ms, e) {
379
432
  }
380
433
  // ─── External drop (HTML5 DnD) ──────────────────────
381
434
  // A class chip dragged in from outside the calendar; the cell it is over
382
- // lights up so the day it would land on is never in doubt.
383
- let dropDayMs = $state(null);
435
+ // lights up and a line on its axis says the time it would land at.
436
+ let drop = $state(null);
384
437
  function onCellDragOver(e, ms) {
385
- if (!onexternaldrop || readOnly || !dayDropStart(ms)) return;
438
+ if (!onexternaldrop || readOnly) return;
439
+ const at = pointerTime(e.currentTarget, e.clientY);
440
+ if (!dayDropStart(ms, at)) {
441
+ drop = null;
442
+ return;
443
+ }
386
444
  e.preventDefault();
387
445
  if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
388
- dropDayMs = ms;
446
+ if (drop?.day !== ms || drop.at !== at) drop = {
447
+ day: ms,
448
+ at
449
+ };
389
450
  }
390
- function onCellDragLeave(ms) {
391
- if (dropDayMs === ms) dropDayMs = null;
451
+ function onCellDragLeave(e, ms) {
452
+ // Leaving for a chip inside the same cell is not leaving the cell.
453
+ if (e.currentTarget.contains(e.relatedTarget)) return;
454
+ if (drop?.day === ms) drop = null;
392
455
  }
393
456
  function onCellDrop(e, ms) {
394
- dropDayMs = null;
457
+ drop = null;
395
458
  if (!onexternaldrop || readOnly || !e.dataTransfer) return;
396
- const start = dayDropStart(ms);
459
+ const start = dayDropStart(ms, pointerTime(e.currentTarget, e.clientY));
397
460
  if (!start) return;
398
461
  e.preventDefault();
399
462
  onexternaldrop({
@@ -421,6 +484,11 @@ let evCellW = 100;
421
484
  * step is the row under the pointer, found by hit-test, not by division.
422
485
  * Held as a timestamp: an extension can renumber rows mid-drag. */
423
486
  let evStartWeekMs = 0;
487
+ /** The day cell the press landed in; the drag moves the event by the days
488
+ * between it and the cell now under the pointer. */
489
+ let evPressDayMs = 0;
490
+ /** The grid runs right-to-left (`dir="rtl"`): a rightward drag goes back. */
491
+ let evRtl = false;
424
492
  const dragPreviewEvent = $derived.by(() => {
425
493
  const payload = drag?.active && drag.mode === "move" ? drag.payload : null;
426
494
  if (!payload?.eventId) return null;
@@ -446,15 +514,16 @@ function timedEventsForDay(day) {
446
514
  const previewKeySnapshot = new Map();
447
515
  function dragPreviewTimedForDay(dayMs) {
448
516
  const ev = dragPreviewEvent;
449
- if (!ev || isAllDay(ev) || isMultiDay(ev)) return null;
517
+ if (!ev || inAllDayStrip(ev)) return null;
518
+ // Where the chip would sit: its start day (as in the week rows)
450
519
  const dayEnd = addDaysMs(dayMs, 1);
451
- const hit = ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs;
520
+ const hit = ev.start.getTime() >= dayMs && ev.start.getTime() < dayEnd;
452
521
  if (hit) previewKeySnapshot.set("timed", ev.id);
453
522
  return hit ? ev : null;
454
523
  }
455
524
  function dragPreviewSegmentForDay(dayMs) {
456
525
  const ev = dragPreviewEvent;
457
- if (!ev || !isAllDay(ev) && !isMultiDay(ev)) return null;
526
+ if (!ev || !inAllDayStrip(ev)) return null;
458
527
  const seg = segmentForDay(ev, dayMs);
459
528
  if (seg) previewKeySnapshot.set(dayMs, `${ev.id}:${seg.dayIndex}`);
460
529
  return seg;
@@ -532,19 +601,45 @@ function onEventPointerDown(e, ev) {
532
601
  evDragStarted = false;
533
602
  evDragId = ev.id;
534
603
  evDragEvent = ev;
604
+ const cellMs = Number(e.currentTarget.closest("[data-day]")?.dataset.day);
605
+ evPressDayMs = Number.isFinite(cellMs) ? cellMs : sod(ev.start.getTime());
535
606
  window.addEventListener("pointermove", onEvWindowPointerMove);
536
607
  window.addEventListener("pointerup", onEvWindowPointerUp, { once: true });
537
608
  window.addEventListener("pointercancel", onEvWindowPointerCancel, { once: true });
538
609
  }
610
+ /** The day `cols` rendered columns away from the press cell, in the row
611
+ * whose start is `rowMs`. Columns are counted over the days actually shown
612
+ * (hideDays leaves gaps in the dates, not in the columns); running off a
613
+ * row's end carries into the neighbouring row. */
614
+ function dayAtColumn(rowMs, cols) {
615
+ const startRow = weeks.findIndex((w) => w.weekStart === evStartWeekMs);
616
+ let r = weeks.findIndex((w) => w.weekStart === rowMs);
617
+ if (startRow < 0 || r < 0) return null;
618
+ const pressCol = weeks[startRow].days.findIndex((d) => d.ms === evPressDayMs);
619
+ if (pressCol < 0) return null;
620
+ let col = pressCol + cols;
621
+ while (col < 0 && r > 0) {
622
+ r--;
623
+ col += weeks[r].days.length;
624
+ }
625
+ while (col >= weeks[r].days.length && r < weeks.length - 1) {
626
+ col -= weeks[r].days.length;
627
+ r++;
628
+ }
629
+ const days = weeks[r].days;
630
+ if (!days.length) return null;
631
+ return days[Math.max(0, Math.min(days.length - 1, col))].ms;
632
+ }
539
633
  /** Where the dragged event sits for the pointer's current offset. */
540
634
  function updateDragFromPointer() {
541
635
  const ev = evDragEvent;
542
636
  if (!drag || !ev) return;
543
- const dayOffset = Math.round((evLastX - evDragStartX) / evCellW);
637
+ const dx = (evLastX - evDragStartX) * (evRtl ? -1 : 1);
638
+ const colOffset = Math.round(dx / evCellW);
544
639
  const nowWeekMs = weekMsAtY(evLastY);
545
- // A vertical row step spans one period (customDays), not always 7 days
546
- const weekOffset = evStartWeekMs && nowWeekMs ? Math.round((nowWeekMs - evStartWeekMs) / (customDays * DAY_MS)) : 0;
547
- const deltaDays = dayOffset + weekOffset * customDays;
640
+ const target = evStartWeekMs && nowWeekMs ? dayAtColumn(nowWeekMs, colOffset) : null;
641
+ // Without a mapped cell (the rows are mid-rebuild) keep the press day
642
+ const deltaDays = target === null ? 0 : diffDays(target, evPressDayMs);
548
643
  drag.updatePointer(new Date(addDaysMs(ev.start.getTime(), deltaDays)), new Date(addDaysMs(ev.end.getTime(), deltaDays)));
549
644
  }
550
645
  // ─── Auto-scroll under a drag ───────────────────────
@@ -590,6 +685,7 @@ function onEvWindowPointerMove(e) {
590
685
  evDragging = true;
591
686
  evCellW = getCellWidth();
592
687
  evStartWeekMs = weekMsAtY(evDragStartY);
688
+ evRtl = !!el && getComputedStyle(el).direction === "rtl";
593
689
  drag.beginMove(ev.id, ev.start, ev.end);
594
690
  }
595
691
  setAutoScroll(e.clientY);
@@ -607,6 +703,8 @@ function cleanupEvDrag() {
607
703
  evAnchor = undefined;
608
704
  evDragMovable = false;
609
705
  evStartWeekMs = 0;
706
+ evPressDayMs = 0;
707
+ evRtl = false;
610
708
  }
611
709
  function onEvWindowPointerUp() {
612
710
  if (!evDragStarted) {
@@ -631,9 +729,28 @@ function onWindowKeydown(e) {
631
729
  // ─── "+N more" per-cell expansion ───────────────────
632
730
  let expandedCells = $state({});
633
731
  // ─── Roving tabindex for grid cells ─────────────────
634
- // One tabbable cell (last focused, else today); arrows move focus.
732
+ // One tabbable cell (last focused, else today, else the first shown day of
733
+ // the anchor week — today may be a hidden day or outside the buffer, and
734
+ // the grid must never drop out of the tab order); arrows move focus.
635
735
  let focusedCellMs = $state(null);
636
- const tabbableCellMs = $derived(focusedCellMs ?? todayMs);
736
+ const tabbableCellMs = $derived.by(() => {
737
+ let fallback = null;
738
+ let anchorFirst = null;
739
+ let focusedShown = false;
740
+ let todayShown = false;
741
+ for (const w of weeks) {
742
+ if (!w.days.length) continue;
743
+ fallback ??= w.days[0].ms;
744
+ if (w.weekStart === anchorPeriodStart) anchorFirst = w.days[0].ms;
745
+ for (const d of w.days) {
746
+ if (d.ms === focusedCellMs) focusedShown = true;
747
+ if (d.ms === todayMs) todayShown = true;
748
+ }
749
+ }
750
+ if (focusedShown) return focusedCellMs;
751
+ if (todayShown) return todayMs;
752
+ return anchorFirst ?? fallback;
753
+ });
637
754
  function onCellKeydown(e, ms) {
638
755
  if (e.key === "Enter" || e.key === " ") {
639
756
  e.preventDefault();
@@ -730,14 +847,14 @@ function onCellKeydown(e, ms) {
730
847
  class:wg-cell--weekend={day.isWeekend}
731
848
  class:wg-cell--disabled={disabledSet.has(day.ms)}
732
849
  class:wg-cell--expanded={isExpanded}
733
- class:wg-cell--drop={dropDayMs === day.ms}
850
+ class:wg-cell--drop={drop?.day === day.ms}
734
851
  role="gridcell"
735
852
  data-day={day.ms}
736
853
  tabindex={day.ms === tabbableCellMs ? 0 : -1}
737
854
  aria-label="{new Date(day.ms).toLocaleDateString(locale ?? 'en-US', { weekday: 'long', month: 'short', day: 'numeric' })}{day.isToday ? ` (${L.today.toLowerCase()})` : ''}, {L.nEvents(day.events.length + day.allDaySegments.length)}"
738
855
  onclick={(e) => handleDayCellClick(day.ms, e)}
739
856
  ondragover={(e) => onCellDragOver(e, day.ms)}
740
- ondragleave={() => onCellDragLeave(day.ms)}
857
+ ondragleave={(e) => onCellDragLeave(e, day.ms)}
741
858
  ondrop={(e) => onCellDrop(e, day.ms)}
742
859
  onfocus={() => { focusedCellMs = day.ms; }}
743
860
  onkeydown={(e) => onCellKeydown(e, day.ms)}
@@ -767,9 +884,9 @@ function onCellKeydown(e, ms) {
767
884
  {@const isoDay = jsDay === 0 ? 7 : jsDay}
768
885
  {#each blockedSlots as slot, i (i)}
769
886
  {#if (!slot.day || slot.day === isoDay) && slot.end > startHour && slot.start < endHour}
770
- {@const slotRange = `${_fmtTime(new Date(day.ms + slot.start * HOUR_MS), locale)} – ${_fmtTime(new Date(day.ms + slot.end * HOUR_MS), locale)}`}
771
- {@const bandTop = timeTop(slot.start * HOUR_MS)}
772
- {@const bandH = Math.max(14, timeTop(slot.end * HOUR_MS) - bandTop)}
887
+ {@const slotRange = `${_fmtTime(new Date(atHour(day.ms, slot.start)), locale)} – ${_fmtTime(new Date(atHour(day.ms, slot.end)), locale)}`}
888
+ {@const bandTop = timeTop(slot.start)}
889
+ {@const bandH = Math.max(14, timeTop(slot.end) - bandTop)}
773
890
  <!-- A chip over the band would cut its label in half: the title keeps it -->
774
891
  {@const covered = tops.some((t) => t < bandTop + bandH && t + CHIP_H > bandTop)}
775
892
  <div
@@ -777,7 +894,7 @@ function onCellKeydown(e, ms) {
777
894
  style:top="{bandTop}px"
778
895
  style:height="{bandH}px"
779
896
  title="{slot.label ? `${slot.label}, ` : ''}{slotRange}"
780
- aria-label="{slot.label || 'Unavailable'}, {slotRange}"
897
+ aria-label="{slot.label || L.unavailable}, {slotRange}"
781
898
  >
782
899
  {#if slot.label && !covered}
783
900
  <span class="wg-blocked-label">{slot.label}</span>
@@ -806,7 +923,7 @@ function onCellKeydown(e, ms) {
806
923
  style:margin-top="{tops[i] - (i ? tops[i - 1] + CHIP_H : 0)}px"
807
924
  role="button"
808
925
  tabindex="0"
809
- aria-label="{ev.title}, {fmtAmPm(ev.start)} – {fmtAmPm(ev.end)}{ev.status === 'cancelled' ? ` (cancelled)` : ''}{ev.status === 'tentative' ? ` (tentative)` : ''}{ev.status === 'full' ? ` (full)` : ''}{ev.status === 'limited' ? ` (limited)` : ''}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
926
+ aria-label="{ev.title}, {fmtAmPm(ev.start)} – {fmtAmPm(ev.end)}{statusText(ev)}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
810
927
  onpointerdown={(e) => onEventPointerDown(e, ev)}
811
928
  onpointerenter={() => oneventhover?.(ev)}
812
929
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev, e.currentTarget.getBoundingClientRect()); } }}
@@ -827,7 +944,7 @@ function onCellKeydown(e, ms) {
827
944
  <div
828
945
  class="wg-ev wg-ev--drag-preview"
829
946
  style:--ev-color={previewTimedEvent.color ?? 'var(--dt-accent)'}
830
- style:top="{timeTop(previewTimedEvent.start.getTime() - day.ms)}px"
947
+ style:top="{timeTop(hourOf(previewTimedEvent.start.getTime(), day.ms))}px"
831
948
  aria-hidden="true"
832
949
  in:previewReceive={{ key: previewKeySnapshot.get('timed') ?? '' }}
833
950
  out:previewSend={{ key: previewKeySnapshot.get('timed') ?? '' }}
@@ -835,6 +952,11 @@ function onCellKeydown(e, ms) {
835
952
  {@render timedEventContent(previewTimedEvent, inset)}
836
953
  </div>
837
954
  {/if}
955
+ {#if drop?.day === day.ms}
956
+ <div class="wg-now wg-drop" style:top="{timeTop(drop.at / HOUR_MS) + CHIP_H / 2}px" aria-hidden="true">
957
+ <span class="wg-now-time">{fmtAmPm(new Date(atHour(day.ms, drop.at / HOUR_MS)))}</span>
958
+ </div>
959
+ {/if}
838
960
  {#if nowY !== null}
839
961
  <div class="wg-now" style:top="{nowY}px" aria-hidden="true">
840
962
  <span class="wg-now-time">{fmtAmPm(new Date(clock.tick))}</span>
@@ -1230,6 +1352,16 @@ function onCellKeydown(e, ms) {
1230
1352
  font: 500 9px / 1 var(--dt-sans, system-ui, sans-serif);
1231
1353
  color: var(--dt-accent, #2563eb);
1232
1354
  }
1355
+ /* Where a dragged-in class would land: the now-line's shape, solid */
1356
+ .wg-drop {
1357
+ z-index: 3;
1358
+ }
1359
+ .wg-drop::after {
1360
+ border-top-style: solid;
1361
+ }
1362
+ .wg-drop .wg-now-time {
1363
+ font-weight: 600;
1364
+ }
1233
1365
 
1234
1366
  .wg-ev {
1235
1367
  position: relative; /* above the blocked bands */