@nomideusz/svelte-calendar 0.16.0 → 0.20.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.
@@ -0,0 +1,1217 @@
1
+ <!--
2
+ week-scroll — the multi-week vertical scroller (Hey Calendar style).
3
+
4
+ This is the PlannerWeek that 97f7f492 (2026-08-01) replaced with the
5
+ vertical time grid, restored as an optional view beside 'week-planner'.
6
+
7
+ Modelled after Hey Calendar's week view:
8
+ • Weeks stack vertically. Scroll up = past, down = future.
9
+ • Day headers in each week row: "MON 23", "TUE 24", with accent pill for today.
10
+ • Month label in left gutter, vertical bottom-to-top.
11
+ • Events are clean horizontal bars with colour fill, "9AM- 10AM Title" inline.
12
+ • Generous whitespace, thin dividers, minimal chrome.
13
+ -->
14
+ <script lang="ts">import { onMount, tick, untrack } from "svelte";
15
+ import { flip } from "svelte/animate";
16
+ import { crossfade } from "svelte/transition";
17
+ import { prefersReducedMotion } from "svelte/motion";
18
+ import { useCalendarContext } from "../shared/context.svelte.js";
19
+ import EventContent from "../shared/EventContent.svelte";
20
+ import { createClock } from "../../core/clock.svelte.js";
21
+ import { DAY_MS, HOUR_MS, sod } from "../../core/time.js";
22
+ import { startOfWeek as sowFn, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
23
+ import { weekdayShort, monthLong, fmtTime as _fmtTime, getLabels } from "../../core/locale.js";
24
+ import { createChipFit } from "../shared/chip-fit.svelte.js";
25
+ const L = $derived(getLabels());
26
+ let { mondayStart = true, locale, height = 520, events = [], style = "", focusDate, oneventclick, oneventcreate, onexternaldrop, selectedEventId = null, readOnly = false, visibleHours } = $props();
27
+ const ctx = useCalendarContext();
28
+ const clock = createClock(ctx.timezone);
29
+ // Drag ghost flies between day cells instead of teleporting.
30
+ // No fallback: without a counterpart (drag start/end) it appears/disappears instantly.
31
+ const [previewSend, previewReceive] = crossfade({ duration: () => prefersReducedMotion.current ? 0 : 160 });
32
+ const drag = $derived(ctx.drag);
33
+ // Cards flip only while a drag is live: a data load must never animate
34
+ // every chip into place (that read as the whole grid jittering).
35
+ const ANIM = $derived(prefersReducedMotion.current || !drag?.active ? 0 : 180);
36
+ const commitDragCtx = $derived(ctx.commitDrag);
37
+ const viewState = $derived(ctx.viewState);
38
+ const loadRangeCtx = $derived(ctx.loadRange);
39
+ const equalDays = $derived(ctx.equalDays);
40
+ const showDates = $derived(ctx.showDates);
41
+ const hideDays = $derived(ctx.hideDays);
42
+ const blockedSlots = $derived(ctx.blockedSlots);
43
+ const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
44
+ const minDuration = $derived(ctx.minDuration);
45
+ const autoHeight = $derived(ctx.autoHeight);
46
+ const oneventhover = $derived(ctx.oneventhover);
47
+ const disabledSet = $derived(ctx.disabledSet);
48
+ // ─── Buffer config ─────────────────────────────
49
+ const INITIAL_BUFFER = 8;
50
+ const EXTEND_BY = 8;
51
+ const EDGE_PX = 200;
52
+ let bufferBefore = $state(INITIAL_BUFFER);
53
+ let bufferAfter = $state(INITIAL_BUFFER);
54
+ const MAX_EVENTS_SHOWN = 5;
55
+ // ─── Geometry ───────────────────────────────────
56
+ // A row is as tall as its busiest day: a week with seven classes on Monday
57
+ // is worth the height. It was briefly a constant, which made the scroll
58
+ // arithmetic exact and clipped exactly the days you most need to read.
59
+ // Nothing needs equal rows now — the prepend is compensated by row
60
+ // identity, a drag maps by hit-testing the row under the pointer, and the
61
+ // mount re-centres once the events have landed.
62
+ const CHIP_H = 22;
63
+ const CHIP_GAP = 3;
64
+ const ROW_MIN = 170;
65
+ const ROW_MARGIN = 12;
66
+ /** Distance between two rows' tops; only a fallback now. */
67
+ function rowPitch() {
68
+ const rows = el?.querySelectorAll("[data-week]");
69
+ return rows && rows.length > 1 ? rows[1].offsetTop - rows[0].offsetTop : ROW_MIN + ROW_MARGIN;
70
+ }
71
+ const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
72
+ let internalFocusMs = $state(_initMs);
73
+ let lastExternalMs = _initMs;
74
+ let el;
75
+ function scrollWeekIntoContainer(targetMs, behavior = "auto") {
76
+ if (!el) return;
77
+ let target = null;
78
+ if (targetMs !== undefined) {
79
+ // Find the week row containing this date
80
+ const rows = el.querySelectorAll("[data-week]");
81
+ for (const row of rows) {
82
+ const weekMs = Number(row.dataset.week);
83
+ if (weekMs <= targetMs && targetMs < weekMs + customDays * DAY_MS) {
84
+ target = row;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ // Fall back to current week
90
+ if (!target) target = el.querySelector(".wg-week--current");
91
+ if (!target) return;
92
+ const targetTop = target.offsetTop - (el.clientHeight - target.offsetHeight) / 2;
93
+ el.scrollTo({
94
+ top: Math.max(0, targetTop),
95
+ behavior
96
+ });
97
+ }
98
+ // ─── Derived ────────────────────────────────────────
99
+ const todayMs = $derived(clock.today);
100
+ const customDays = $derived(viewState?.dayCount ?? 7);
101
+ const anchorPeriodStart = $derived(customDays === 7 ? sowFn(internalFocusMs, mondayStart) : sod(internalFocusMs));
102
+ // ─── Declare load range for entire visible buffer ────────
103
+ // Instead of calling store.load() directly, we tell Calendar
104
+ // what range we need. Calendar's single $effect handles loading.
105
+ $effect(() => {
106
+ if (!loadRangeCtx) return;
107
+ const rangeStart = new Date(anchorPeriodStart - bufferBefore * customDays * DAY_MS);
108
+ const rangeEnd = new Date(anchorPeriodStart + (bufferAfter + 1) * customDays * DAY_MS);
109
+ loadRangeCtx.set({
110
+ start: rangeStart,
111
+ end: rangeEnd
112
+ });
113
+ return () => loadRangeCtx.set(null);
114
+ });
115
+ const weeks = $derived.by(() => {
116
+ const result = [];
117
+ for (let w = -bufferBefore; w <= bufferAfter; w++) {
118
+ const periodStart = anchorPeriodStart + w * customDays * DAY_MS;
119
+ const isCurrent = todayMs >= periodStart && todayMs < periodStart + customDays * DAY_MS;
120
+ const days = [];
121
+ for (let d = 0; d < customDays; d++) {
122
+ const ms = periodStart + d * DAY_MS;
123
+ const date = new Date(ms);
124
+ const dayNum = date.getDate();
125
+ const dow = date.getDay();
126
+ const isWeekend = dow === 0 || dow === 6;
127
+ const isToday = ms === todayMs;
128
+ const isPast = equalDays ? false : ms < todayMs;
129
+ const isFirstOfMonth = dayNum === 1;
130
+ const monthLabel = d === 0 || isFirstOfMonth ? monthLong(ms, locale).toUpperCase() : null;
131
+ const dayEnd = ms + DAY_MS;
132
+ const dayEventsAll = events.filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > ms).sort((a, b) => a.start.getTime() - b.start.getTime());
133
+ // Separate all-day / multi-day from timed events
134
+ const timedEvents = [];
135
+ const allDaySegments = [];
136
+ for (const ev of dayEventsAll) {
137
+ if (isAllDay(ev) || isMultiDay(ev)) {
138
+ const seg = segmentForDay(ev, ms);
139
+ if (seg) allDaySegments.push(seg);
140
+ } else {
141
+ timedEvents.push(ev);
142
+ }
143
+ }
144
+ days.push({
145
+ ms,
146
+ dayNum,
147
+ isToday,
148
+ isPast,
149
+ isWeekend,
150
+ isFirstOfMonth,
151
+ monthLabel,
152
+ events: timedEvents,
153
+ allDaySegments
154
+ });
155
+ }
156
+ // Month label: show when first day of period is day 1-7 (for 7-day), or first day of period (for custom)
157
+ const startDate = new Date(periodStart);
158
+ const showMonth = customDays === 7 ? startDate.getDate() <= 7 : startDate.getDate() <= customDays;
159
+ const monthLabel = showMonth ? monthLong(periodStart, locale).toUpperCase() : null;
160
+ result.push({
161
+ weekStart: periodStart,
162
+ isCurrent,
163
+ monthLabel,
164
+ days
165
+ });
166
+ }
167
+ // Filter hidden days if hideDays is set
168
+ if (hideDays?.length) {
169
+ for (const row of result) {
170
+ row.days = row.days.filter((d) => {
171
+ const isoDay = new Date(d.ms).getDay();
172
+ // Convert JS day (0=Sun) to ISO (7=Sun)
173
+ const iso = isoDay === 0 ? 7 : isoDay;
174
+ return !hideDays.includes(iso);
175
+ });
176
+ }
177
+ }
178
+ return result;
179
+ });
180
+ // ─── Format helpers ─────────────────────────────────
181
+ function fmtAmPm(d) {
182
+ return _fmtTime(d, locale);
183
+ }
184
+ // ─── Scroll to current week on mount ────────────────
185
+ onMount(() => {
186
+ tick().then(() => scrollWeekIntoContainer());
187
+ return () => cancelAnimationFrame(syncRaf);
188
+ });
189
+ // Rows grow when their events arrive, which moves every row below them —
190
+ // including the one we centred on an empty grid. Centre once more when the
191
+ // first events land, and never again, so this can't fight a reader who has
192
+ // already taken hold of the scroller.
193
+ let settled = $state(false);
194
+ $effect(() => {
195
+ if (settled || !events.length) return;
196
+ settled = true;
197
+ tick().then(() => {
198
+ if (!touched) scrollWeekIntoContainer(internalFocusMs);
199
+ });
200
+ });
201
+ /** A real reader gesture (wheel or press), as opposed to a scroll we caused
202
+ * ourselves. Keyboard is not watched: the re-centre happens milliseconds
203
+ * after mount, before anyone has reached for a key, and a keydown handler
204
+ * on the grid element would need a tabindex it should not have. */
205
+ let touched = false;
206
+ /** Attached, not bound in markup: a handler on the `role="grid"` element
207
+ * would demand a tabindex the scroller should not carry. */
208
+ function watchTouch(root) {
209
+ const on = () => {
210
+ touched = true;
211
+ };
212
+ root.addEventListener("wheel", on, { passive: true });
213
+ root.addEventListener("pointerdown", on);
214
+ return () => {
215
+ root.removeEventListener("wheel", on);
216
+ root.removeEventListener("pointerdown", on);
217
+ };
218
+ }
219
+ // ─── External navigation (arrows, goToday) ──────────
220
+ // Only a focus that lands in ANOTHER period than the one the scroll
221
+ // already reported re-anchors the buffer. The scroll sync below writes
222
+ // the centre week back through viewState, and that value comes back
223
+ // here as `focusDate` — compared by day it could differ by hours (zoned
224
+ // dates) and the view re-anchored on itself: the whole buffer rebuilt
225
+ // around the centre week and scrolled to it. That was the big jump.
226
+ const periodOf = (ms) => customDays === 7 ? sowFn(ms, mondayStart) : sod(ms);
227
+ $effect(() => {
228
+ const ext = focusDate ? sod(focusDate.getTime()) : clock.today;
229
+ if (periodOf(ext) !== periodOf(lastExternalMs)) {
230
+ lastExternalMs = ext;
231
+ internalFocusMs = ext;
232
+ bufferBefore = INITIAL_BUFFER;
233
+ bufferAfter = INITIAL_BUFFER;
234
+ tick().then(() => scrollWeekIntoContainer(ext));
235
+ }
236
+ });
237
+ /** Push the week at the viewport centre to viewState (drives the header label + Today button). */
238
+ function syncFocusFromScroll() {
239
+ if (!el || !viewState) return;
240
+ const centerY = el.scrollTop + el.clientHeight / 2;
241
+ const rows = el.querySelectorAll("[data-week]");
242
+ for (const row of rows) {
243
+ if (row.offsetTop + row.offsetHeight >= centerY) {
244
+ const ms = Number(row.dataset.week);
245
+ if (Number.isFinite(ms) && ms !== lastExternalMs) {
246
+ lastExternalMs = ms;
247
+ viewState.setFocusDate(new Date(ms));
248
+ }
249
+ return;
250
+ }
251
+ }
252
+ }
253
+ let extending = false;
254
+ let syncRaf = 0;
255
+ function handleUserScroll() {
256
+ // syncFocusFromScroll does querySelectorAll + offsetTop reads —
257
+ // rAF-throttle it so scroll ticks stay cheap.
258
+ if (!syncRaf) {
259
+ syncRaf = requestAnimationFrame(() => {
260
+ syncRaf = 0;
261
+ if (!extending) syncFocusFromScroll();
262
+ });
263
+ }
264
+ if (!el || extending) return;
265
+ // Extend buffer when user scrolls near the edge
266
+ if (el.scrollTop < EDGE_PX) {
267
+ extending = true;
268
+ // Anchor on the first rendered row: after the prepend the same row
269
+ // must sit at the same offset from the viewport top. (The browser's
270
+ // own scroll anchoring is off on .wg-body — two compensations for
271
+ // one insertion was the jump.)
272
+ const first = el.querySelector("[data-week]");
273
+ const key = first?.dataset.week;
274
+ const offset = first ? first.offsetTop - el.scrollTop : 0;
275
+ bufferBefore += EXTEND_BY;
276
+ tick().then(() => {
277
+ const row = key ? el.querySelector(`[data-week="${key}"]`) : null;
278
+ el.scrollTop = row ? row.offsetTop - offset : el.scrollTop + EXTEND_BY * rowPitch();
279
+ extending = false;
280
+ });
281
+ } else {
282
+ const bottomRemaining = el.scrollHeight - el.clientHeight - el.scrollTop;
283
+ if (bottomRemaining < EDGE_PX) {
284
+ bufferAfter += EXTEND_BY;
285
+ }
286
+ }
287
+ }
288
+ /** Where a new thing lands on this day, or null when the day refuses it.
289
+ * A cell is a whole day here — there is no time axis to drop onto — so
290
+ * everything starts at the first visible hour. One rule, so an empty-cell
291
+ * click and a drop from outside can never disagree about a day. */
292
+ function dayDropStart(ms) {
293
+ if (disabledSet.has(ms)) return null;
294
+ const startHour = visibleHours?.[0] ?? 9;
295
+ if (blockedSlots?.length) {
296
+ const jsDay = new Date(ms).getDay();
297
+ const isoDay = jsDay === 0 ? 7 : jsDay;
298
+ const blocked = blockedSlots.some((slot) => {
299
+ if (slot.day && slot.day !== isoDay) return false;
300
+ return startHour >= slot.start && startHour < slot.end;
301
+ });
302
+ if (blocked) return null;
303
+ }
304
+ return new Date(ms + startHour * HOUR_MS);
305
+ }
306
+ function handleDayCellClick(ms, e) {
307
+ const target = e.target;
308
+ if (target.closest(".wg-ev, .wg-ad, .wg-ev-more")) return;
309
+ if (readOnly || !oneventcreate) return;
310
+ const start = dayDropStart(ms);
311
+ if (!start) return;
312
+ const durMin = minDuration ? Math.max(60, minDuration) : 60;
313
+ oneventcreate({
314
+ start,
315
+ end: new Date(start.getTime() + durMin * 6e4)
316
+ });
317
+ }
318
+ // ─── External drop (HTML5 DnD) ──────────────────────
319
+ // A class chip dragged in from outside the calendar; the cell it is over
320
+ // lights up so the day it would land on is never in doubt.
321
+ let dropDayMs = $state(null);
322
+ function onCellDragOver(e, ms) {
323
+ if (!onexternaldrop || readOnly || !dayDropStart(ms)) return;
324
+ e.preventDefault();
325
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
326
+ dropDayMs = ms;
327
+ }
328
+ function onCellDragLeave(ms) {
329
+ if (dropDayMs === ms) dropDayMs = null;
330
+ }
331
+ function onCellDrop(e, ms) {
332
+ dropDayMs = null;
333
+ if (!onexternaldrop || readOnly || !e.dataTransfer) return;
334
+ const start = dayDropStart(ms);
335
+ if (!start) return;
336
+ e.preventDefault();
337
+ onexternaldrop({
338
+ start,
339
+ dataTransfer: e.dataTransfer
340
+ });
341
+ }
342
+ // ─── Event drag-to-move ───────────────────────────────────────
343
+ const DRAG_THRESHOLD = 8;
344
+ let evDragStartX = 0;
345
+ let evDragStartY = 0;
346
+ let evDragStarted = false;
347
+ let evDragging = $state(false);
348
+ let evDragId = $state(null);
349
+ let evDragEvent = null;
350
+ /** The pressed chip's rect, caught before any re-render can move it. */
351
+ let evAnchor;
352
+ /** Whether THIS press may become a move (see onEventPointerDown). */
353
+ let evDragMovable = false;
354
+ let evLastX = 0;
355
+ let evLastY = 0;
356
+ // Frozen at drag start: columns do not reflow mid-drag.
357
+ let evCellW = 100;
358
+ /** The week the press started in — rows differ in height, so the vertical
359
+ * step is the row under the pointer, found by hit-test, not by division.
360
+ * Held as a timestamp: an extension can renumber rows mid-drag. */
361
+ let evStartWeekMs = 0;
362
+ const dragPreviewEvent = $derived.by(() => {
363
+ const payload = drag?.active && drag.mode === "move" ? drag.payload : null;
364
+ if (!payload?.eventId) return null;
365
+ const ev = events.find((event) => event.id === payload.eventId);
366
+ if (!ev) return null;
367
+ return {
368
+ ...ev,
369
+ start: payload.start,
370
+ end: payload.end
371
+ };
372
+ });
373
+ function isDraggedEvent(eventId) {
374
+ return dragPreviewEvent?.id === eventId;
375
+ }
376
+ function timedEventsForDay(day) {
377
+ if (!dragPreviewEvent) return day.events;
378
+ return day.events.filter((ev) => ev.id !== dragPreviewEvent.id);
379
+ }
380
+ // Crossfade keys for the previews, snapshotted at render time. Transition
381
+ // params are evaluated lazily at unmount — after the drag payload is
382
+ // already null — so they must never read the reactive preview directly.
383
+ // Plain Map (not $state): written during render, read only by transitions.
384
+ const previewKeySnapshot = new Map();
385
+ function dragPreviewTimedForDay(dayMs) {
386
+ const ev = dragPreviewEvent;
387
+ if (!ev || isAllDay(ev) || isMultiDay(ev)) return null;
388
+ const dayEnd = dayMs + DAY_MS;
389
+ const hit = ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs;
390
+ if (hit) previewKeySnapshot.set("timed", ev.id);
391
+ return hit ? ev : null;
392
+ }
393
+ function dragPreviewSegmentForDay(dayMs) {
394
+ const ev = dragPreviewEvent;
395
+ if (!ev || !isAllDay(ev) && !isMultiDay(ev)) return null;
396
+ const seg = segmentForDay(ev, dayMs);
397
+ if (seg) previewKeySnapshot.set(dayMs, `${ev.id}:${seg.dayIndex}`);
398
+ return seg;
399
+ }
400
+ // ─── Chip labels ────────────────────────────────
401
+ // A chip is one line and holds more than fits. It gives up the room first
402
+ // and the time second, rather than letting CSS cut the title mid-letter;
403
+ // createChipFit measures, fitParts decides. Before the first measurement
404
+ // (SSR, first paint) a chip is its title.
405
+ const CHIP_PAD_X = 12;
406
+ const CHIP_GAP_X = 5;
407
+ const fit = createChipFit({
408
+ slot: ".wg-cell",
409
+ fonts: {
410
+ title: ".wg-probe .wg-ev-title",
411
+ time: ".wg-probe .wg-ev-time",
412
+ room: ".wg-probe .wg-ev-loc"
413
+ }
414
+ });
415
+ function chipParts(ev) {
416
+ return fit.parts([
417
+ {
418
+ key: "title",
419
+ text: ev.title,
420
+ font: fit.fonts.title,
421
+ priority: 0
422
+ },
423
+ {
424
+ key: "time",
425
+ text: fmtAmPm(ev.start),
426
+ font: fit.fonts.time,
427
+ priority: 1,
428
+ extra: CHIP_GAP_X
429
+ },
430
+ {
431
+ key: "room",
432
+ text: ev.location ?? "",
433
+ font: fit.fonts.room,
434
+ priority: 2,
435
+ extra: CHIP_GAP_X
436
+ }
437
+ ], CHIP_PAD_X);
438
+ }
439
+ function getCellWidth() {
440
+ const cell = el?.querySelector(".wg-cell");
441
+ return cell ? cell.getBoundingClientRect().width : 100;
442
+ }
443
+ /** The week row under this viewport Y, as its start timestamp. */
444
+ function weekMsAtY(clientY) {
445
+ const rows = el?.querySelectorAll("[data-week]");
446
+ if (!rows?.length) return 0;
447
+ for (const row of rows) {
448
+ if (clientY < row.getBoundingClientRect().bottom) return Number(row.dataset.week);
449
+ }
450
+ return Number(rows[rows.length - 1].dataset.week);
451
+ }
452
+ function onEventPointerDown(e, ev) {
453
+ if (e.button !== 0) return;
454
+ e.stopPropagation();
455
+ evAnchor = e.currentTarget.getBoundingClientRect();
456
+ // Even an event that cannot move — a read-only view, a read-only event,
457
+ // and most of a schedule is read-only occurrences — still goes through
458
+ // the pointerup path, so a plain click opens it. Refusing the press
459
+ // outright made three quarters of the chips dead to the mouse.
460
+ evDragMovable = !!drag && !readOnly && !ev.data?.readOnly;
461
+ evDragStartX = e.clientX;
462
+ evDragStartY = e.clientY;
463
+ evLastX = e.clientX;
464
+ evLastY = e.clientY;
465
+ evDragStarted = false;
466
+ evDragId = ev.id;
467
+ evDragEvent = ev;
468
+ window.addEventListener("pointermove", onEvWindowPointerMove);
469
+ window.addEventListener("pointerup", onEvWindowPointerUp, { once: true });
470
+ window.addEventListener("pointercancel", onEvWindowPointerCancel, { once: true });
471
+ }
472
+ /** Where the dragged event sits for the pointer's current offset. */
473
+ function updateDragFromPointer() {
474
+ const ev = evDragEvent;
475
+ if (!drag || !ev) return;
476
+ const dayOffset = Math.round((evLastX - evDragStartX) / evCellW);
477
+ const nowWeekMs = weekMsAtY(evLastY);
478
+ // A vertical row step spans one period (customDays), not always 7 days
479
+ const weekOffset = evStartWeekMs && nowWeekMs ? Math.round((nowWeekMs - evStartWeekMs) / (customDays * DAY_MS)) : 0;
480
+ const deltaMs = (dayOffset + weekOffset * customDays) * DAY_MS;
481
+ drag.updatePointer(new Date(ev.start.getTime() + deltaMs), new Date(ev.end.getTime() + deltaMs));
482
+ }
483
+ // ─── Auto-scroll under a drag ───────────────────────
484
+ // Weeks the target is in may be off-screen — without this, moving an event
485
+ // a month out means dropping it, scrolling, and dragging again. The week
486
+ // is re-read from whatever row is under the pointer after each step, so
487
+ // the scroll moves the target without any origin bookkeeping.
488
+ const AUTO_EDGE = 56;
489
+ const AUTO_STEP = 14;
490
+ let autoDir = 0;
491
+ let autoRaf = 0;
492
+ function autoScrollTick() {
493
+ autoRaf = 0;
494
+ if (!autoDir || !el) return;
495
+ const before = el.scrollTop;
496
+ el.scrollTop += autoDir * AUTO_STEP;
497
+ if (el.scrollTop !== before) updateDragFromPointer();
498
+ autoRaf = requestAnimationFrame(autoScrollTick);
499
+ }
500
+ function setAutoScroll(clientY) {
501
+ if (!el) return;
502
+ const r = el.getBoundingClientRect();
503
+ const dir = clientY < r.top + AUTO_EDGE ? -1 : clientY > r.bottom - AUTO_EDGE ? 1 : 0;
504
+ if (dir === autoDir) return;
505
+ autoDir = dir;
506
+ if (dir && !autoRaf) autoRaf = requestAnimationFrame(autoScrollTick);
507
+ }
508
+ function stopAutoScroll() {
509
+ autoDir = 0;
510
+ if (autoRaf) cancelAnimationFrame(autoRaf);
511
+ autoRaf = 0;
512
+ }
513
+ function onEvWindowPointerMove(e) {
514
+ const ev = evDragEvent;
515
+ if (!evDragMovable || !drag || !ev || evDragId !== ev.id) return;
516
+ evLastX = e.clientX;
517
+ evLastY = e.clientY;
518
+ const dx = e.clientX - evDragStartX;
519
+ const dy = e.clientY - evDragStartY;
520
+ if (!evDragStarted && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
521
+ if (!evDragStarted) {
522
+ evDragStarted = true;
523
+ evDragging = true;
524
+ evCellW = getCellWidth();
525
+ evStartWeekMs = weekMsAtY(evDragStartY);
526
+ drag.beginMove(ev.id, ev.start, ev.end);
527
+ }
528
+ setAutoScroll(e.clientY);
529
+ updateDragFromPointer();
530
+ }
531
+ function cleanupEvDrag() {
532
+ window.removeEventListener("pointermove", onEvWindowPointerMove);
533
+ window.removeEventListener("pointerup", onEvWindowPointerUp);
534
+ window.removeEventListener("pointercancel", onEvWindowPointerCancel);
535
+ stopAutoScroll();
536
+ evDragStarted = false;
537
+ evDragging = false;
538
+ evDragId = null;
539
+ evDragEvent = null;
540
+ evAnchor = undefined;
541
+ evDragMovable = false;
542
+ evStartWeekMs = 0;
543
+ }
544
+ function onEvWindowPointerUp() {
545
+ if (!evDragStarted) {
546
+ // A click, not a drag: hand the host the chip's rect so a panel can
547
+ // open beside it rather than in the middle of the screen.
548
+ if (evDragEvent) oneventclick?.(evDragEvent, evAnchor);
549
+ } else if (drag) {
550
+ commitDragCtx?.();
551
+ }
552
+ cleanupEvDrag();
553
+ }
554
+ function onEvWindowPointerCancel() {
555
+ if (drag && evDragStarted) drag.cancel();
556
+ cleanupEvDrag();
557
+ }
558
+ // ─── Escape cancels an in-flight drag ───────────────
559
+ function onWindowKeydown(e) {
560
+ if (e.key !== "Escape" || !drag?.active) return;
561
+ drag.cancel();
562
+ cleanupEvDrag();
563
+ }
564
+ // ─── "+N more" per-cell expansion ───────────────────
565
+ let expandedCells = $state({});
566
+ // ─── Roving tabindex for grid cells ─────────────────
567
+ // One tabbable cell (last focused, else today); arrows move focus.
568
+ let focusedCellMs = $state(null);
569
+ const tabbableCellMs = $derived(focusedCellMs ?? todayMs);
570
+ function onCellKeydown(e, ms) {
571
+ if (e.key === "Enter" || e.key === " ") {
572
+ e.preventDefault();
573
+ handleDayCellClick(ms, e);
574
+ return;
575
+ }
576
+ let step = 0;
577
+ if (e.key === "ArrowRight") step = DAY_MS;
578
+ else if (e.key === "ArrowLeft") step = -DAY_MS;
579
+ else if (e.key === "ArrowDown") step = customDays * DAY_MS;
580
+ else if (e.key === "ArrowUp") step = -customDays * DAY_MS;
581
+ if (step === 0) return;
582
+ e.preventDefault();
583
+ // Walk in the step direction until a rendered cell is found
584
+ // (skips hidden days; bails at the buffer edge).
585
+ let target = ms + step;
586
+ for (let i = 0; i < 7; i++) {
587
+ const cell = el?.querySelector(`[data-day="${target}"]`);
588
+ if (cell) {
589
+ focusedCellMs = target;
590
+ cell.focus();
591
+ cell.scrollIntoView({ block: "nearest" });
592
+ return;
593
+ }
594
+ target += step < 0 ? -DAY_MS : DAY_MS;
595
+ }
596
+ }
597
+ </script>
598
+
599
+ {#snippet allDaySegmentContent(seg: DaySegment)}
600
+ {#if seg.isStart}
601
+ <span class="wg-ad-title">{seg.ev.title}</span>
602
+ {:else}
603
+ <span class="wg-ad-cont" aria-hidden="true">◂</span>
604
+ <span class="wg-ad-title">{seg.ev.title}</span>
605
+ {/if}
606
+ {#if !seg.isEnd && seg.totalDays > 1}
607
+ <span class="wg-ad-arrow" aria-hidden="true">▸</span>
608
+ {/if}
609
+ {/snippet}
610
+
611
+ {#snippet timedEventContent(ev: TimelineEvent)}
612
+ {@const parts = chipParts(ev)}
613
+ <EventContent event={ev}>
614
+ {#if parts.time}<span class="wg-ev-time">{fmtAmPm(ev.start)}</span>{/if}
615
+ <span class="wg-ev-title">{ev.title}</span>
616
+ {#if parts.room}
617
+ <span class="wg-ev-loc">{ev.location}</span>
618
+ {/if}
619
+ </EventContent>
620
+ {/snippet}
621
+
622
+ <svelte:window onkeydown={onWindowKeydown} />
623
+
624
+ <div class="wg" class:wg--auto={autoHeight} style={style || undefined} style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')} style:--wg-row-min="{ROW_MIN}px" style:--wg-chip-h="{CHIP_H}px" style:--wg-chip-gap="{CHIP_GAP}px" style:--wg-row-margin="{ROW_MARGIN}px">
625
+ <div
626
+ class="wg-body"
627
+ bind:this={el}
628
+ {@attach fit.watch}
629
+ {@attach watchTouch}
630
+ onscroll={handleUserScroll}
631
+ role="grid"
632
+ aria-label={L.multiWeekGrid}
633
+ >
634
+ <!-- Font probe: a dropped part cannot report the font it wanted, so the
635
+ three chip fonts are read from here instead of from a live chip. -->
636
+ <div class="wg-probe" aria-hidden="true">
637
+ <span class="wg-ev-time"></span><span class="wg-ev-title"></span><span class="wg-ev-loc"></span>
638
+ </div>
639
+ {#each weeks as week (week.weekStart)}
640
+ <div class="wg-week" class:wg-week--current={week.isCurrent} data-week={week.weekStart} role="presentation">
641
+ <div class="wg-week-body" role="presentation">
642
+ <!-- Day columns (header inside each cell) -->
643
+ <div class="wg-days" role="row">
644
+ {#each week.days as day (day.ms)}
645
+ {@const visibleAllDaySegments = day.allDaySegments.filter((seg) => !isDraggedEvent(seg.ev.id))}
646
+ {@const visibleTimedEvents = timedEventsForDay(day)}
647
+ {@const isExpanded = expandedCells[day.ms] ?? false}
648
+ {@const timedCap = Math.max(0, MAX_EVENTS_SHOWN - visibleAllDaySegments.length)}
649
+ {@const hiddenCount = Math.max(0, visibleTimedEvents.length - timedCap)}
650
+ {@const previewTimedEvent = dragPreviewTimedForDay(day.ms)}
651
+ {@const previewSegment = dragPreviewSegmentForDay(day.ms)}
652
+ <div
653
+ class="wg-cell"
654
+ class:wg-cell--today={day.isToday}
655
+ class:wg-cell--past={day.isPast}
656
+ class:wg-cell--weekend={day.isWeekend}
657
+ class:wg-cell--disabled={disabledSet.has(day.ms)}
658
+ class:wg-cell--expanded={isExpanded}
659
+ class:wg-cell--drop={dropDayMs === day.ms}
660
+ role="gridcell"
661
+ data-day={day.ms}
662
+ tabindex={day.ms === tabbableCellMs ? 0 : -1}
663
+ 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)}"
664
+ onclick={(e) => handleDayCellClick(day.ms, e)}
665
+ ondragover={(e) => onCellDragOver(e, day.ms)}
666
+ ondragleave={() => onCellDragLeave(day.ms)}
667
+ ondrop={(e) => onCellDrop(e, day.ms)}
668
+ onfocus={() => { focusedCellMs = day.ms; }}
669
+ onkeydown={(e) => onCellKeydown(e, day.ms)}
670
+ >
671
+ <!-- Day label in top-right corner -->
672
+ <div class="wg-cell-hd" class:wg-cell-hd--today={day.isToday}>
673
+ {#if showDates}
674
+ <span class="wg-day-num" class:wg-day-num--today={day.isToday}>
675
+ {day.dayNum}
676
+ </span>
677
+ {/if}
678
+ <span class="wg-day-wd">{weekdayShort(day.ms, locale)}</span>
679
+ </div>
680
+
681
+ <!-- Custom day header snippet -->
682
+ {#if dayHeaderSnippet}
683
+ <div class="wg-cell-custom-header">
684
+ {@render dayHeaderSnippet({ date: new Date(day.ms), isToday: day.isToday, dayName: weekdayShort(day.ms, locale) })}
685
+ </div>
686
+ {/if}
687
+
688
+ <!-- Blocked slots indicator -->
689
+ {#if blockedSlots?.length}
690
+ {@const jsDay = new Date(day.ms).getDay()}
691
+ {@const isoDay = jsDay === 0 ? 7 : jsDay}
692
+ {#each blockedSlots as slot, i (i)}
693
+ {#if !slot.day || slot.day === isoDay}
694
+ {@const slotRange = `${_fmtTime(new Date(day.ms + slot.start * HOUR_MS), locale)} – ${_fmtTime(new Date(day.ms + slot.end * HOUR_MS), locale)}`}
695
+ <div
696
+ class="wg-blocked"
697
+ title="{slot.label ? `${slot.label}, ` : ''}{slotRange}"
698
+ aria-label="{slot.label || 'Unavailable'}, {slotRange}"
699
+ >
700
+ {#if slot.label}
701
+ <span class="wg-blocked-label">{slot.label}</span>
702
+ {/if}
703
+ </div>
704
+ {/if}
705
+ {/each}
706
+ {/if}
707
+
708
+ <!-- All-day / multi-day events -->
709
+ {#if visibleAllDaySegments.length > 0 || previewSegment}
710
+ <div class="wg-allday">
711
+ {#each visibleAllDaySegments as seg (seg.ev.id)}
712
+ <div
713
+ animate:flip={{ duration: ANIM }}
714
+ in:previewReceive={{ key: `${seg.ev.id}:${seg.dayIndex}` }}
715
+ out:previewSend={{ key: `${seg.ev.id}:${seg.dayIndex}` }}
716
+ class="wg-ad"
717
+ class:wg-ad--start={seg.isStart}
718
+ class:wg-ad--end={seg.isEnd}
719
+ class:wg-ad--mid={!seg.isStart && !seg.isEnd}
720
+ class:wg-ad--selected={selectedEventId === seg.ev.id}
721
+ style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
722
+ role="button"
723
+ tabindex="0"
724
+ aria-label="{seg.ev.title}{seg.totalDays > 1 ? `, ${L.dayNOfTotal(seg.dayIndex, seg.totalDays)}` : `, ${L.allDay}`}"
725
+ onpointerdown={(e) => onEventPointerDown(e, seg.ev)}
726
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(seg.ev, e.currentTarget.getBoundingClientRect()); } }}
727
+ >
728
+ {@render allDaySegmentContent(seg)}
729
+ </div>
730
+ {/each}
731
+ {#if previewSegment}
732
+ <!-- key by event id + dayIndex: multi-day previews render one segment
733
+ per cell, and each pairs with its own real-card counterpart -->
734
+ <div
735
+ class="wg-ad wg-ad--drag-preview"
736
+ class:wg-ad--start={previewSegment.isStart}
737
+ class:wg-ad--end={previewSegment.isEnd}
738
+ class:wg-ad--mid={!previewSegment.isStart && !previewSegment.isEnd}
739
+ style:--ev-color={previewSegment.ev.color ?? 'var(--dt-accent)'}
740
+ aria-hidden="true"
741
+ in:previewReceive={{ key: previewKeySnapshot.get(day.ms) ?? '' }}
742
+ out:previewSend={{ key: previewKeySnapshot.get(day.ms) ?? '' }}
743
+ >
744
+ {@render allDaySegmentContent(previewSegment)}
745
+ </div>
746
+ {/if}
747
+ </div>
748
+ {/if}
749
+
750
+ <!-- Timed events -->
751
+ <div class="wg-cell-events">
752
+ {#each visibleTimedEvents.slice(0, isExpanded ? visibleTimedEvents.length : timedCap) as ev (ev.id)}
753
+ <!-- send/receive keyed by event id pair the card with the drag ghost:
754
+ drag start morphs card → ghost, drop morphs ghost → placed card -->
755
+ <div
756
+ animate:flip={{ duration: ANIM }}
757
+ in:previewReceive={{ key: ev.id }}
758
+ out:previewSend={{ key: ev.id }}
759
+ class="wg-ev"
760
+ class:wg-ev--selected={selectedEventId === ev.id}
761
+ class:wg-ev--current={ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
762
+ class:wg-ev--dragging={evDragging && evDragId === ev.id}
763
+ class:wg-ev--readonly={ev.data?.readOnly}
764
+ class:wg-ev--cancelled={ev.status === 'cancelled'}
765
+ class:wg-ev--tentative={ev.status === 'tentative'}
766
+ class:wg-ev--full={ev.status === 'full'}
767
+ class:wg-ev--limited={ev.status === 'limited'}
768
+ style:--ev-color={ev.color ?? 'var(--dt-accent)'}
769
+ role="button"
770
+ tabindex="0"
771
+ 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})` : ''}"
772
+ onpointerdown={(e) => onEventPointerDown(e, ev)}
773
+ onpointerenter={() => oneventhover?.(ev)}
774
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev, e.currentTarget.getBoundingClientRect()); } }}
775
+ >
776
+ {@render timedEventContent(ev)}
777
+ </div>
778
+ {/each}
779
+ {#if previewTimedEvent}
780
+ <div
781
+ class="wg-ev wg-ev--drag-preview"
782
+ style:--ev-color={previewTimedEvent.color ?? 'var(--dt-accent)'}
783
+ aria-hidden="true"
784
+ in:previewReceive={{ key: previewKeySnapshot.get('timed') ?? '' }}
785
+ out:previewSend={{ key: previewKeySnapshot.get('timed') ?? '' }}
786
+ >
787
+ {@render timedEventContent(previewTimedEvent)}
788
+ </div>
789
+ {/if}
790
+ </div>
791
+ {#if hiddenCount > 0}
792
+ <button
793
+ type="button"
794
+ class="wg-ev-more"
795
+ aria-expanded={isExpanded}
796
+ onclick={(e) => { e.stopPropagation(); expandedCells[day.ms] = !isExpanded; }}
797
+ >{isExpanded ? L.showLess : L.nMore(hiddenCount)}</button>
798
+ {/if}
799
+ </div>
800
+ {/each}
801
+ </div>
802
+ </div>
803
+ </div>
804
+ {/each}
805
+ </div>
806
+
807
+ </div>
808
+
809
+ <style>
810
+ /* ─── Container ──────────────────────────────────── */
811
+ .wg {
812
+ position: relative;
813
+ overflow: hidden;
814
+ display: flex;
815
+ flex-direction: column;
816
+ user-select: none;
817
+ font-variant-numeric: tabular-nums;
818
+ }
819
+ .wg--auto { overflow: visible; }
820
+
821
+ /* ─── Scrollable body ────────────────────────────── */
822
+ .wg-body {
823
+ flex: 1;
824
+ overflow-y: auto;
825
+ /* The prepend is compensated by hand (handleUserScroll); the browser's
826
+ anchoring on top of it moved the content twice. */
827
+ overflow-anchor: none;
828
+ /* Seven columns scroll horizontally at narrow widths instead of squishing */
829
+ overflow-x: auto;
830
+ box-sizing: border-box;
831
+ scrollbar-width: thin;
832
+ scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.1)) transparent;
833
+ }
834
+ .wg--auto .wg-body { overflow-y: visible; }
835
+
836
+ .wg-body::-webkit-scrollbar { width: 4px; }
837
+ .wg-body::-webkit-scrollbar-thumb {
838
+ background: var(--dt-scrollbar, rgba(0, 0, 0, 0.1));
839
+ border-radius: 4px;
840
+ }
841
+ .wg-body::-webkit-scrollbar-track { background: transparent; }
842
+
843
+ .wg-probe {
844
+ position: absolute;
845
+ visibility: hidden;
846
+ height: 0;
847
+ overflow: hidden;
848
+ pointer-events: none;
849
+ }
850
+
851
+ /* ─── Week row ───────────────────────────────────── */
852
+ .wg-week {
853
+ display: flex;
854
+ border-radius: 10px;
855
+ margin: var(--wg-row-margin, 12px) 8px;
856
+ border: 1.5px solid var(--dt-border, rgba(0, 0, 0, 0.08));
857
+ overflow: hidden;
858
+ }
859
+
860
+ .wg-week--current {
861
+ background: var(--dt-today-bg, rgba(37, 99, 235, 0.04));
862
+ /* Border width stays constant (no layout shift); emphasis via box-shadow */
863
+ border-color: var(--dt-accent, #2563eb);
864
+ box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
865
+ }
866
+
867
+ /* ─── Week body ──────────────────────────────────── */
868
+ .wg-week-body {
869
+ flex: 1;
870
+ min-width: 0;
871
+ display: flex;
872
+ flex-direction: column;
873
+ }
874
+
875
+ /* ─── Day columns ────────────────────────────────── */
876
+ .wg-days {
877
+ display: flex;
878
+ flex: 1;
879
+ }
880
+
881
+ .wg-cell {
882
+ flex: 1;
883
+ position: relative;
884
+ display: flex;
885
+ flex-direction: column;
886
+ min-width: 90px;
887
+ min-height: var(--wg-row-min, 170px);
888
+ box-sizing: border-box;
889
+ padding: 4px 4px 8px;
890
+ border-right: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
891
+ cursor: pointer;
892
+ transition: background 0.15s;
893
+ }
894
+
895
+ .wg-cell:last-child { border-right: none; }
896
+ .wg-cell:hover { background: var(--dt-hover, rgba(0, 0, 0, 0.015)); }
897
+
898
+ .wg-cell--today { background: var(--dt-today-bg, rgba(37, 99, 235, 0.04)); }
899
+ .wg-cell--drop {
900
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 12%, transparent) !important;
901
+ box-shadow: inset 0 0 0 2px var(--dt-accent, #2563eb);
902
+ }
903
+ .wg-cell--today:hover { background: color-mix(in srgb, var(--dt-accent, #2563eb) 6%, transparent); }
904
+
905
+ /* Dim non-current weeks with a subtle wash + softer header text instead of
906
+ a subtree opacity, so event content keeps full contrast everywhere. */
907
+ .wg-week:not(.wg-week--current) .wg-cell {
908
+ background: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 2.5%, transparent);
909
+ }
910
+ .wg-week:not(.wg-week--current) .wg-day-num {
911
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
912
+ }
913
+ .wg-week--current .wg-cell--past {
914
+ background: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 2.5%, transparent);
915
+ }
916
+
917
+ /* equalDays: when no cells are marked past, all are full brightness */
918
+
919
+ .wg-cell--weekend { background: var(--dt-weekend-bg, rgba(0, 0, 0, 0.012)); }
920
+
921
+ /* ─── Disabled cell ──────────────────────────────── */
922
+ .wg-cell--disabled {
923
+ background: repeating-linear-gradient(
924
+ 45deg,
925
+ transparent,
926
+ transparent 6px,
927
+ var(--dt-border, rgba(0, 0, 0, 0.08)) 6px,
928
+ var(--dt-border, rgba(0, 0, 0, 0.08)) 7px
929
+ ) !important;
930
+ }
931
+
932
+ /* ─── Blocked slot indicator ─────────────────────── */
933
+ .wg-blocked {
934
+ display: flex;
935
+ align-items: center;
936
+ gap: 3px;
937
+ padding: 2px 4px;
938
+ border-radius: 3px;
939
+ background: repeating-linear-gradient(
940
+ -45deg,
941
+ color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 4%, transparent),
942
+ color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 4%, transparent) 3px,
943
+ transparent 3px,
944
+ transparent 6px
945
+ );
946
+ margin-bottom: 2px;
947
+ min-height: 14px;
948
+ }
949
+
950
+ .wg-blocked-label {
951
+ font: 500 10px/1 var(--dt-sans, system-ui, sans-serif);
952
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
953
+ text-transform: uppercase;
954
+ letter-spacing: 0.04em;
955
+ white-space: nowrap;
956
+ }
957
+
958
+ /* ─── Custom day header ──────────────────────────── */
959
+ .wg-cell-custom-header {
960
+ padding: 0 4px 2px;
961
+ }
962
+
963
+ /* ─── Cell header (day label top-right) ──────────── */
964
+ .wg-cell-hd {
965
+ display: flex;
966
+ align-items: center;
967
+ justify-content: flex-end;
968
+ gap: 4px;
969
+ padding: 4px 5px 2px 0;
970
+ margin-bottom: 2px;
971
+ }
972
+
973
+ .wg-day-wd {
974
+ font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
975
+ letter-spacing: 0.04em;
976
+ text-transform: uppercase;
977
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
978
+ }
979
+
980
+ .wg-week--current .wg-day-wd {
981
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
982
+ }
983
+
984
+ .wg-cell-hd--today .wg-day-wd {
985
+ color: var(--dt-accent, #2563eb);
986
+ font-weight: 600;
987
+ }
988
+
989
+ .wg-day-num {
990
+ font: 700 14px / 1 var(--dt-sans, system-ui, sans-serif);
991
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
992
+ }
993
+
994
+ .wg-week--current .wg-day-num {
995
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
996
+ }
997
+
998
+ .wg-day-num--today {
999
+ color: var(--dt-accent, #2563eb);
1000
+ font-weight: 900;
1001
+ }
1002
+
1003
+ /* ─── All-day / multi-day events ─────────────────── */
1004
+ .wg-allday {
1005
+ display: flex;
1006
+ flex-direction: column;
1007
+ gap: var(--wg-chip-gap, 3px);
1008
+ margin-bottom: var(--wg-chip-gap, 3px);
1009
+ flex-shrink: 0;
1010
+ }
1011
+
1012
+ .wg-ad {
1013
+ display: flex;
1014
+ align-items: center;
1015
+ gap: 3px;
1016
+ padding: 0 5px;
1017
+ height: var(--wg-chip-h, 22px);
1018
+ box-sizing: border-box;
1019
+ flex-shrink: 0;
1020
+ border-radius: 3px;
1021
+ background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
1022
+ cursor: pointer;
1023
+ overflow: hidden;
1024
+ transition: background 0.12s;
1025
+ min-height: 18px;
1026
+ }
1027
+
1028
+ .wg-ad--drag-preview {
1029
+ position: relative;
1030
+ z-index: 8;
1031
+ opacity: 0.95;
1032
+ pointer-events: none;
1033
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--ev-color) 26%, rgba(0, 0, 0, 0.22));
1034
+ outline: 1px solid color-mix(in srgb, var(--ev-color) 42%, transparent);
1035
+ cursor: grabbing;
1036
+ }
1037
+
1038
+ .wg-ad:hover {
1039
+ background: color-mix(in srgb, var(--ev-color) 32%, var(--dt-surface, var(--dt-bg, #ffffff)));
1040
+ }
1041
+
1042
+ .wg-ad--start {
1043
+ border-left: 2.5px solid var(--ev-color);
1044
+ }
1045
+
1046
+ .wg-ad--mid {
1047
+ border-radius: 0;
1048
+ border-left: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1049
+ }
1050
+
1051
+ .wg-ad--end:not(.wg-ad--start) {
1052
+ border-radius: 0 3px 3px 0;
1053
+ border-left: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1054
+ }
1055
+
1056
+ .wg-ad--selected {
1057
+ box-shadow: 0 0 0 1.5px var(--ev-color);
1058
+ }
1059
+
1060
+ .wg-ad-title {
1061
+ font: 500 10px / 1.1 var(--dt-sans, system-ui, sans-serif);
1062
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
1063
+ white-space: nowrap;
1064
+ overflow: hidden;
1065
+ text-overflow: ellipsis;
1066
+ flex: 1;
1067
+ }
1068
+
1069
+ .wg-ad-cont {
1070
+ font-size: 10px;
1071
+ color: var(--ev-color);
1072
+ flex-shrink: 0;
1073
+ line-height: 1;
1074
+ }
1075
+
1076
+ .wg-ad-arrow {
1077
+ font-size: 10px;
1078
+ color: var(--ev-color);
1079
+ flex-shrink: 0;
1080
+ margin-left: auto;
1081
+ line-height: 1;
1082
+ }
1083
+
1084
+ /* ─── Events ─────────────────────────────────────── */
1085
+ .wg-cell-events {
1086
+ position: relative;
1087
+ display: flex;
1088
+ flex-direction: column;
1089
+ gap: var(--wg-chip-gap, 3px);
1090
+ }
1091
+
1092
+ .wg-ev {
1093
+ display: flex;
1094
+ align-items: center;
1095
+ flex-wrap: nowrap;
1096
+ gap: 0 5px;
1097
+ height: var(--wg-chip-h, 22px);
1098
+ box-sizing: border-box;
1099
+ flex-shrink: 0;
1100
+ padding: 0 6px;
1101
+ border-radius: 4px;
1102
+ background: color-mix(in srgb, var(--ev-color) 15%, var(--dt-surface, var(--dt-bg, #ffffff)));
1103
+ cursor: pointer;
1104
+ overflow: hidden;
1105
+ transition: background 0.12s;
1106
+ }
1107
+
1108
+ .wg-ev:hover {
1109
+ background: color-mix(in srgb, var(--ev-color) 25%, var(--dt-surface, var(--dt-bg, #ffffff)));
1110
+ }
1111
+
1112
+ .wg-ev--drag-preview {
1113
+ position: relative;
1114
+ z-index: 8;
1115
+ opacity: 0.95;
1116
+ pointer-events: none;
1117
+ background: color-mix(in srgb, var(--ev-color) 28%, var(--dt-surface, var(--dt-bg, #ffffff)));
1118
+ box-shadow: 0 6px 18px color-mix(in srgb, var(--ev-color) 24%, rgba(0, 0, 0, 0.22));
1119
+ outline: 1px solid color-mix(in srgb, var(--ev-color) 42%, transparent);
1120
+ cursor: grabbing;
1121
+ }
1122
+
1123
+ .wg-ev--selected {
1124
+ box-shadow: 0 0 0 1.5px var(--ev-color);
1125
+ }
1126
+
1127
+ .wg-ev--current {
1128
+ background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, var(--dt-bg, #ffffff)));
1129
+ }
1130
+
1131
+ .wg-ev--cancelled {
1132
+ opacity: 0.5;
1133
+ }
1134
+ .wg-ev--cancelled .wg-ev-title {
1135
+ text-decoration: line-through;
1136
+ }
1137
+ .wg-ev--tentative {
1138
+ opacity: 0.65;
1139
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1140
+ }
1141
+ .wg-ev--full {
1142
+ opacity: 0.55;
1143
+ }
1144
+ .wg-ev--limited {
1145
+ opacity: 0.65;
1146
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 40%, transparent);
1147
+ }
1148
+ .wg-ev--readonly {
1149
+ cursor: default;
1150
+ }
1151
+
1152
+ .wg-ev-time {
1153
+ font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
1154
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1155
+ flex-shrink: 0;
1156
+ white-space: nowrap;
1157
+ }
1158
+
1159
+ .wg-ev-title {
1160
+ font: 500 12px / 1.1 var(--dt-sans, system-ui, sans-serif);
1161
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
1162
+ white-space: nowrap;
1163
+ overflow: hidden;
1164
+ text-overflow: ellipsis;
1165
+ min-width: 0;
1166
+ flex: 1 1 auto;
1167
+ }
1168
+
1169
+ .wg-ev-loc {
1170
+ font: 400 10px / 1 var(--dt-sans, system-ui, sans-serif);
1171
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1172
+ white-space: nowrap;
1173
+ flex-shrink: 0; /* shown only when measured to fit — never squeezed */
1174
+ }
1175
+
1176
+ .wg-ev-more {
1177
+ /* Real button — reset chrome, keep the quiet-link look */
1178
+ appearance: none;
1179
+ background: none;
1180
+ border: none;
1181
+ border-radius: 3px;
1182
+ text-align: left;
1183
+ align-self: flex-start;
1184
+ font: 500 10px / 1 var(--dt-sans, system-ui, sans-serif);
1185
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
1186
+ padding: 2px 8px;
1187
+ cursor: pointer;
1188
+ flex-shrink: 0;
1189
+ margin-top: 2px;
1190
+ }
1191
+
1192
+ .wg-ev-more:hover {
1193
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
1194
+ }
1195
+
1196
+ .wg-ev-more:focus-visible {
1197
+ outline: none;
1198
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
1199
+ }
1200
+
1201
+ /* ─── Focus-visible ──────────────────────────────── */
1202
+ .wg-cell:focus-visible {
1203
+ outline: 2px solid var(--dt-accent, #2563eb);
1204
+ outline-offset: -2px;
1205
+ }
1206
+
1207
+ .wg-ev:focus-visible {
1208
+ outline: 2px solid var(--ev-color, var(--dt-accent, #2563eb));
1209
+ outline-offset: 1px;
1210
+ }
1211
+
1212
+ .wg-ev--dragging {
1213
+ cursor: grabbing;
1214
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1215
+ }
1216
+ </style>
1217
+