@nomideusz/svelte-calendar 0.21.0 → 1.0.1

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 +198 -61
  59. package/dist/views/planner/PlannerWeek.svelte +148 -48
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +29 -14
  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>
@@ -1090,7 +1212,7 @@ function onCellKeydown(e, ms) {
1090
1212
  }
1091
1213
 
1092
1214
  .wg-day-wd {
1093
- font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
1215
+ font: 400 11px / 1 var(--dt-sans, system-ui, sans-serif);
1094
1216
  letter-spacing: 0.04em;
1095
1217
  text-transform: uppercase;
1096
1218
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
@@ -1176,7 +1298,7 @@ function onCellKeydown(e, ms) {
1176
1298
  }
1177
1299
 
1178
1300
  .wg-ad-title {
1179
- font: 500 10px / 1.1 var(--dt-sans, system-ui, sans-serif);
1301
+ font: 500 11px / 1.1 var(--dt-sans, system-ui, sans-serif);
1180
1302
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
1181
1303
  white-space: nowrap;
1182
1304
  overflow: hidden;
@@ -1184,16 +1306,19 @@ function onCellKeydown(e, ms) {
1184
1306
  flex: 1;
1185
1307
  }
1186
1308
 
1309
+ /* The event colour as ink can be a pale fill (a host's "no colour" tint);
1310
+ pulled toward the text colour it stays the event's hue and stays legible
1311
+ on the chip in either theme. */
1187
1312
  .wg-ad-cont {
1188
- font-size: 10px;
1189
- color: var(--ev-color);
1313
+ font-size: 11px;
1314
+ color: color-mix(in srgb, var(--ev-color) 45%, var(--dt-text, #000));
1190
1315
  flex-shrink: 0;
1191
1316
  line-height: 1;
1192
1317
  }
1193
1318
 
1194
1319
  .wg-ad-arrow {
1195
- font-size: 10px;
1196
- color: var(--ev-color);
1320
+ font-size: 11px;
1321
+ color: color-mix(in srgb, var(--ev-color) 45%, var(--dt-text, #000));
1197
1322
  flex-shrink: 0;
1198
1323
  margin-left: auto;
1199
1324
  line-height: 1;
@@ -1227,9 +1352,19 @@ function onCellKeydown(e, ms) {
1227
1352
  border-top: 1px dashed var(--dt-accent, #2563eb);
1228
1353
  }
1229
1354
  .wg-now-time {
1230
- font: 500 9px / 1 var(--dt-sans, system-ui, sans-serif);
1355
+ font: 500 10px / 1 var(--dt-sans, system-ui, sans-serif);
1231
1356
  color: var(--dt-accent, #2563eb);
1232
1357
  }
1358
+ /* Where a dragged-in class would land: the now-line's shape, solid */
1359
+ .wg-drop {
1360
+ z-index: 3;
1361
+ }
1362
+ .wg-drop::after {
1363
+ border-top-style: solid;
1364
+ }
1365
+ .wg-drop .wg-now-time {
1366
+ font-weight: 600;
1367
+ }
1233
1368
 
1234
1369
  .wg-ev {
1235
1370
  position: relative; /* above the blocked bands */
@@ -1294,9 +1429,11 @@ function onCellKeydown(e, ms) {
1294
1429
  cursor: default;
1295
1430
  }
1296
1431
 
1432
+ /* On the tinted chip the tertiary ink fell under 4.5:1; the time is the
1433
+ chip's first answer, so it reads in the secondary ink. */
1297
1434
  .wg-ev-time {
1298
- font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
1299
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1435
+ font: 400 11px / 1 var(--dt-sans, system-ui, sans-serif);
1436
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.6));
1300
1437
  flex-shrink: 0;
1301
1438
  white-space: nowrap;
1302
1439
  }
@@ -1312,7 +1449,7 @@ function onCellKeydown(e, ms) {
1312
1449
  }
1313
1450
 
1314
1451
  .wg-ev-loc {
1315
- font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
1452
+ font: 400 11px / 1 var(--dt-sans, system-ui, sans-serif);
1316
1453
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1317
1454
  white-space: nowrap;
1318
1455
  flex-shrink: 0; /* shown only when measured to fit — never squeezed */