@nomideusz/svelte-calendar 0.8.0 → 0.10.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 (37) hide show
  1. package/README.md +29 -8
  2. package/dist/calendar/Calendar.svelte +187 -64
  3. package/dist/calendar/Calendar.svelte.d.ts +14 -0
  4. package/dist/core/clock.svelte.d.ts +1 -1
  5. package/dist/core/clock.svelte.js +8 -4
  6. package/dist/core/locale.d.ts +15 -1
  7. package/dist/core/locale.js +9 -2
  8. package/dist/core/timezone.d.ts +12 -0
  9. package/dist/core/timezone.js +31 -0
  10. package/dist/headless/create-calendar.svelte.js +12 -6
  11. package/dist/headless/types.d.ts +2 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/theme/auto.js +27 -6
  15. package/dist/theme/presets.d.ts +4 -4
  16. package/dist/theme/presets.js +11 -7
  17. package/dist/views/agenda/AgendaDay.svelte +260 -111
  18. package/dist/views/agenda/AgendaWeek.svelte +191 -66
  19. package/dist/views/mobile/MobileDay.svelte +409 -214
  20. package/dist/views/mobile/MobileWeek.svelte +137 -75
  21. package/dist/views/mobile/swipe.d.ts +28 -0
  22. package/dist/views/mobile/swipe.js +64 -0
  23. package/dist/views/month/MonthGrid.svelte +167 -31
  24. package/dist/views/planner/PlannerDay.svelte +156 -81
  25. package/dist/views/planner/PlannerWeek.svelte +1156 -631
  26. package/dist/views/planner/PlannerWeek.svelte.d.ts +2 -0
  27. package/dist/views/shared/context.svelte.d.ts +4 -0
  28. package/dist/views/shared/context.svelte.js +14 -9
  29. package/dist/views/shared/format.d.ts +2 -1
  30. package/dist/views/shared/format.js +8 -5
  31. package/dist/widget/CalendarWidget.svelte +33 -5
  32. package/dist/widget/CalendarWidget.svelte.d.ts +16 -2
  33. package/dist/widget/widget.d.ts +9 -0
  34. package/dist/widget/widget.js +59 -13
  35. package/package.json +1 -1
  36. package/widget/widget.js +3830 -2755
  37. package/widget/svelte-calendar.css +0 -3054
@@ -5,13 +5,13 @@
5
5
  Large touch targets, swipe left/right to change day.
6
6
  Events positioned absolutely within hour lanes.
7
7
  -->
8
- <script lang="ts">import { onMount } from "svelte";
8
+ <script lang="ts">import { untrack } from "svelte";
9
9
  import { useCalendarContext } from "../shared/context.svelte.js";
10
10
  import EventContent from "../shared/EventContent.svelte";
11
11
  import { createClock } from "../../core/clock.svelte.js";
12
12
  import { DAY_MS, HOUR_MS, sod, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
13
- import { fmtH, fmtTime, getLabels } from "../../core/locale.js";
14
- const L = $derived(getLabels());
13
+ import { fmtH, fmtTime } from "../../core/locale.js";
14
+ import { createSwipe } from "./swipe.js";
15
15
  let {
16
16
  height = null,
17
17
  events = [],
@@ -25,6 +25,7 @@ let {
25
25
  visibleHours
26
26
  } = $props();
27
27
  const ctx = useCalendarContext();
28
+ const L = $derived(ctx.labels);
28
29
  const viewState = $derived(ctx.viewState);
29
30
  const autoHeight = $derived(ctx.autoHeight);
30
31
  const oneventhover = $derived(ctx.oneventhover);
@@ -35,7 +36,7 @@ const blockedSlots = $derived(ctx.blockedSlots);
35
36
  const drag = $derived(ctx.drag);
36
37
  const commitDragCtx = $derived(ctx.commitDrag);
37
38
  const SNAP_MS = $derived(ctx.snapInterval * 6e4);
38
- const clock = createClock();
39
+ const clock = createClock(ctx.timezone);
39
40
  const HOUR_HEIGHT = 64;
40
41
  const GUTTER_W = 40;
41
42
  const startHour = $derived(visibleHours?.[0] ?? 0);
@@ -45,7 +46,6 @@ const gridHeight = $derived(hourCount * HOUR_HEIGHT);
45
46
  const dayMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
46
47
  const dayEnd = $derived(dayMs + DAY_MS);
47
48
  const isToday = $derived(dayMs === clock.today);
48
- const isPast = $derived(dayMs < clock.today);
49
49
  const isDisabled = $derived(disabledSet.has(dayMs));
50
50
  $effect(() => {
51
51
  if (!loadRangeCtx) return;
@@ -66,6 +66,10 @@ const allDayEvents = $derived.by(() => {
66
66
  }
67
67
  return segs;
68
68
  });
69
+ const isEmpty = $derived(timedEvents.length === 0 && allDayEvents.length === 0);
70
+ const ALLDAY_MAX = 3;
71
+ let allDayExpanded = $state(false);
72
+ const visibleAllDay = $derived(allDayExpanded ? allDayEvents : allDayEvents.slice(0, ALLDAY_MAX));
69
73
  const positionedEvents = $derived.by(() => {
70
74
  const now = clock.tick;
71
75
  const sorted = [...timedEvents];
@@ -166,45 +170,33 @@ function isBlockedAt(hour) {
166
170
  return hour >= slot.start && hour < slot.end;
167
171
  });
168
172
  }
169
- let el;
170
- let touchStartX = 0;
171
- let touchStartY = 0;
172
- let swiping = false;
173
- let swipeOffset = $state(0);
174
- const SWIPE_THRESHOLD = 50;
175
- function onTouchStart(e) {
176
- const t = e.touches[0];
177
- touchStartX = t.clientX;
178
- touchStartY = t.clientY;
179
- swiping = true;
180
- swipeOffset = 0;
181
- }
182
- function onTouchMove(e) {
183
- if (!swiping) return;
184
- const t = e.touches[0];
185
- const dx = t.clientX - touchStartX;
186
- const dy = t.clientY - touchStartY;
187
- if (Math.abs(dy) > Math.abs(dx) * 0.8) {
188
- swiping = false;
189
- return;
190
- }
191
- swipeOffset = dx;
173
+ function statusText(ev) {
174
+ if (ev.status === "cancelled") return ` (${L.cancelled})`;
175
+ if (ev.status === "tentative") return ` (${L.tentative})`;
176
+ if (ev.status === "full") return ` (${L.full})`;
177
+ if (ev.status === "limited") return ` (${L.limited})`;
178
+ return "";
192
179
  }
193
- function onTouchEnd() {
194
- if (!swiping) {
195
- swipeOffset = 0;
196
- return;
197
- }
198
- if (Math.abs(swipeOffset) > SWIPE_THRESHOLD) {
199
- if (swipeOffset > 0) {
200
- viewState?.prev();
180
+ let swipeOffset = $state(0);
181
+ let swipeAnimate = $state(false);
182
+ const swipe = createSwipe({
183
+ disabled: () => !!drag?.active || mbCreateStarted || mbRsStarted || longPressTimer !== null,
184
+ onmove: (dx) => {
185
+ swipeAnimate = false;
186
+ swipeOffset = dx;
187
+ },
188
+ onend: (dir) => {
189
+ if (dir !== 0) {
190
+ swipeAnimate = false;
191
+ swipeOffset = 0;
192
+ if (dir > 0) viewState?.prev();
193
+ else viewState?.next();
201
194
  } else {
202
- viewState?.next();
195
+ swipeAnimate = true;
196
+ swipeOffset = 0;
203
197
  }
204
198
  }
205
- swipeOffset = 0;
206
- swiping = false;
207
- }
199
+ });
208
200
  function handleGridClick(e) {
209
201
  if (suppressGridClick) {
210
202
  suppressGridClick = false;
@@ -212,22 +204,46 @@ function handleGridClick(e) {
212
204
  }
213
205
  if (!oneventcreate || readOnly || isDisabled) return;
214
206
  if (e.target.closest(".mb-event")) return;
215
- const grid = e.currentTarget;
216
- const rect = grid.getBoundingClientRect();
217
- const y = e.clientY - rect.top + grid.scrollTop;
218
- const hour = startHour + y / HOUR_HEIGHT;
219
- if (isBlockedAt(hour)) return;
220
- const snapHour = Math.floor(hour);
221
- const durMin = minDuration ? Math.max(60, minDuration) : 60;
222
- const start = new Date(dayMs + snapHour * HOUR_MS);
223
- const end = new Date(start.getTime() + durMin * 6e4);
224
- oneventcreate({ start, end });
207
+ const tMs = gridTimeMs(e.clientY);
208
+ if (isBlockedAt((tMs - dayMs) / HOUR_MS)) return;
209
+ const startMs = clampToDay(Math.floor(tMs / SNAP_MS) * SNAP_MS);
210
+ const durMin = minDuration ?? 60;
211
+ oneventcreate({ start: new Date(startMs), end: new Date(startMs + durMin * 6e4) });
212
+ }
213
+ function onGridKeydown(e) {
214
+ if (e.key !== "Enter" && e.key !== " ") return;
215
+ if (!oneventcreate || readOnly || isDisabled) return;
216
+ e.preventDefault();
217
+ const raw = isToday ? clock.tick : dayMs + startHour * HOUR_MS;
218
+ const startMs = clampToDay(Math.ceil(raw / SNAP_MS) * SNAP_MS);
219
+ if (isBlockedAt((startMs - dayMs) / HOUR_MS)) return;
220
+ const durMin = minDuration ?? 60;
221
+ oneventcreate({ start: new Date(startMs), end: new Date(startMs + durMin * 6e4) });
225
222
  }
226
223
  const CREATE_THRESHOLD = 4;
224
+ const LONG_PRESS_MS = 350;
225
+ const LONG_PRESS_TOLERANCE = 8;
227
226
  let suppressGridClick = false;
227
+ let mbCreateStartX = 0;
228
228
  let mbCreateStartY = 0;
229
229
  let mbCreateAnchorMs = 0;
230
230
  let mbCreateStarted = false;
231
+ let longPressTimer = null;
232
+ function blockTouchScroll(e) {
233
+ e.preventDefault();
234
+ }
235
+ function addTouchScrollBlock() {
236
+ window.addEventListener("touchmove", blockTouchScroll, { passive: false });
237
+ }
238
+ function removeTouchScrollBlock() {
239
+ window.removeEventListener("touchmove", blockTouchScroll);
240
+ }
241
+ function clearLongPress() {
242
+ if (longPressTimer !== null) {
243
+ clearTimeout(longPressTimer);
244
+ longPressTimer = null;
245
+ }
246
+ }
231
247
  function gridTimeMs(clientY) {
232
248
  const rect = gridEl.getBoundingClientRect();
233
249
  const y = clientY - rect.top + gridEl.scrollTop;
@@ -236,12 +252,26 @@ function gridTimeMs(clientY) {
236
252
  function clampToDay(ms) {
237
253
  return Math.max(dayMs + startHour * HOUR_MS, Math.min(dayMs + endHour * HOUR_MS, ms));
238
254
  }
255
+ function startGridCreate() {
256
+ if (!drag) return;
257
+ mbCreateStarted = true;
258
+ mbCreateAnchorMs = clampToDay(Math.floor(mbCreateAnchorMs / SNAP_MS) * SNAP_MS);
259
+ drag.beginCreate(new Date(mbCreateAnchorMs), new Date(mbCreateAnchorMs + SNAP_MS));
260
+ addTouchScrollBlock();
261
+ }
239
262
  function onGridPointerDown(e) {
240
263
  if (e.button !== 0 || !drag || !oneventcreate || readOnly || isDisabled) return;
241
264
  if (e.target.closest(".mb-event")) return;
265
+ mbCreateStartX = e.clientX;
242
266
  mbCreateStartY = e.clientY;
243
267
  mbCreateAnchorMs = gridTimeMs(e.clientY);
244
268
  mbCreateStarted = false;
269
+ if (e.pointerType === "touch") {
270
+ longPressTimer = setTimeout(() => {
271
+ longPressTimer = null;
272
+ startGridCreate();
273
+ }, LONG_PRESS_MS);
274
+ }
245
275
  window.addEventListener("pointermove", onGridCreateMove);
246
276
  window.addEventListener("pointerup", onGridCreateUp, { once: true });
247
277
  window.addEventListener("pointercancel", onGridCreateCancel, { once: true });
@@ -249,10 +279,14 @@ function onGridPointerDown(e) {
249
279
  function onGridCreateMove(e) {
250
280
  if (!drag) return;
251
281
  if (!mbCreateStarted) {
282
+ if (longPressTimer !== null) {
283
+ const moved = Math.hypot(e.clientX - mbCreateStartX, e.clientY - mbCreateStartY);
284
+ if (moved > LONG_PRESS_TOLERANCE) cleanupGridCreate();
285
+ return;
286
+ }
287
+ if (e.pointerType === "touch") return;
252
288
  if (Math.abs(e.clientY - mbCreateStartY) < CREATE_THRESHOLD) return;
253
- mbCreateStarted = true;
254
- mbCreateAnchorMs = clampToDay(Math.floor(mbCreateAnchorMs / SNAP_MS) * SNAP_MS);
255
- drag.beginCreate(new Date(mbCreateAnchorMs), new Date(mbCreateAnchorMs + SNAP_MS));
289
+ startGridCreate();
256
290
  }
257
291
  const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
258
292
  drag.updatePointer(
@@ -261,6 +295,8 @@ function onGridCreateMove(e) {
261
295
  );
262
296
  }
263
297
  function cleanupGridCreate() {
298
+ clearLongPress();
299
+ removeTouchScrollBlock();
264
300
  window.removeEventListener("pointermove", onGridCreateMove);
265
301
  window.removeEventListener("pointerup", onGridCreateUp);
266
302
  window.removeEventListener("pointercancel", onGridCreateCancel);
@@ -280,6 +316,9 @@ function onGridCreateCancel() {
280
316
  if (drag && mbCreateStarted) drag.cancel();
281
317
  cleanupGridCreate();
282
318
  }
319
+ function onGridContextMenu(e) {
320
+ if (mbCreateStarted || longPressTimer !== null) e.preventDefault();
321
+ }
283
322
  let suppressEventClick = false;
284
323
  let mbRsStartY = 0;
285
324
  let mbRsStarted = false;
@@ -303,6 +342,7 @@ function onResizeMove(e) {
303
342
  if (Math.abs(e.clientY - mbRsStartY) < CREATE_THRESHOLD) return;
304
343
  mbRsStarted = true;
305
344
  drag.beginResize(ev.id, mbRsEdge, ev.start, ev.end);
345
+ addTouchScrollBlock();
306
346
  }
307
347
  const snapped = clampToDay(Math.round(gridTimeMs(e.clientY) / SNAP_MS) * SNAP_MS);
308
348
  if (mbRsEdge === "end") {
@@ -314,6 +354,7 @@ function onResizeMove(e) {
314
354
  }
315
355
  }
316
356
  function cleanupResize() {
357
+ removeTouchScrollBlock();
317
358
  window.removeEventListener("pointermove", onResizeMove);
318
359
  window.removeEventListener("pointerup", onResizeUp);
319
360
  window.removeEventListener("pointercancel", onResizeCancel);
@@ -337,11 +378,13 @@ function onResizeCancel() {
337
378
  cleanupResize();
338
379
  }
339
380
  let gridEl;
340
- onMount(() => {
341
- if (nowOffset > 0 && gridEl) {
342
- const scrollTarget = Math.max(0, nowOffset - 120);
343
- gridEl.scrollTop = scrollTarget;
344
- }
381
+ $effect(() => {
382
+ void dayMs;
383
+ const el = gridEl;
384
+ if (!el) return;
385
+ untrack(() => {
386
+ if (nowOffset >= 0) el.scrollTop = Math.max(0, nowOffset - 120);
387
+ });
345
388
  });
346
389
  </script>
347
390
 
@@ -352,147 +395,177 @@ onMount(() => {
352
395
  style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')}
353
396
  role="region"
354
397
  aria-label={L.dayPlanner}
355
- ontouchstart={onTouchStart}
356
- ontouchmove={onTouchMove}
357
- ontouchend={onTouchEnd}
398
+ ontouchstart={swipe.ontouchstart}
399
+ ontouchmove={swipe.ontouchmove}
400
+ ontouchend={swipe.ontouchend}
358
401
  >
359
- <!-- All-day events bar -->
360
- {#if allDayEvents.length > 0}
361
- <div class="mb-allday">
362
- {#each allDayEvents.slice(0, 3) as seg (seg.ev.id)}
363
- <button
364
- class="mb-allday-chip"
365
- class:mb-allday-chip--selected={selectedEventId === seg.ev.id}
366
- style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
367
- onclick={() => oneventclick?.(seg.ev)}
368
- >
369
- <span class="mb-allday-dot"></span>
370
- <span class="mb-allday-title">{seg.ev.title}</span>
371
- {#if seg.totalDays > 1}
372
- <span class="mb-allday-span">{seg.dayIndex}/{seg.totalDays}</span>
373
- {/if}
374
- </button>
375
- {/each}
376
- {#if allDayEvents.length > 3}
377
- <span class="mb-allday-more">{L.nMore(allDayEvents.length - 3)}</span>
378
- {/if}
379
- </div>
380
- {/if}
381
-
382
- <!-- Scrollable time grid -->
383
402
  <div
384
- class="mb-grid"
385
- bind:this={gridEl}
386
- onclick={handleGridClick}
387
- onpointerdown={onGridPointerDown}
388
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleGridClick(e as unknown as MouseEvent); }}
389
- role="grid"
390
- tabindex="-1"
403
+ class="mb-swipe"
404
+ class:mb-swipe--animate={swipeAnimate}
405
+ style:transform={swipeOffset !== 0 ? `translateX(${swipeOffset}px)` : undefined}
391
406
  >
392
- <div class="mb-grid-inner" style:height="{gridHeight}px">
393
- <!-- Hour lanes -->
394
- {#each { length: hourCount } as _, h}
395
- {@const hour = startHour + h}
396
- {@const blocked = isBlockedAt(hour)}
397
- <div
398
- class="mb-hour"
399
- class:mb-hour--blocked={blocked}
400
- style:top="{h * HOUR_HEIGHT}px"
401
- style:height="{HOUR_HEIGHT}px"
402
- >
403
- <div class="mb-hour-label">{fmtH(hour, locale)}</div>
404
- <div class="mb-hour-line"></div>
405
- {#if blocked && blockedSlots}
406
- {@const slot = blockedSlots.find(s => (!s.day || s.day === (new Date(dayMs).getDay() === 0 ? 7 : new Date(dayMs).getDay())) && hour >= s.start && hour < s.end)}
407
- {#if slot?.label}
408
- <span class="mb-blocked-label">{slot.label}</span>
409
- {/if}
410
- {/if}
411
- </div>
412
- {/each}
413
-
414
- <!-- Now line -->
415
- {#if nowOffset >= 0}
416
- <div class="mb-now" style:top="{nowOffset}px">
417
- <span class="mb-now-label">{clock.hm}</span>
418
- <div class="mb-now-line"></div>
419
- </div>
420
- {/if}
421
-
422
- <!-- Events -->
423
- {#each positionedEvents as p (p.ev.id)}
424
- <button
425
- class="mb-event"
426
- class:mb-event--selected={selectedEventId === p.ev.id}
427
- class:mb-event--current={p.isCurrent}
428
- class:mb-event--next={p.isNext}
429
- class:mb-event--cancelled={p.ev.status === 'cancelled'}
430
- class:mb-event--tentative={p.ev.status === 'tentative'}
431
- class:mb-event--full={p.ev.status === 'full'}
432
- class:mb-event--limited={p.ev.status === 'limited'}
433
- class:mb-event--resizing={p.isResizing}
434
- style:top="{p.top}px"
435
- style:height="{p.height}px"
436
- style:left={p.left}
437
- style:width={p.width}
438
- style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
439
- onclick={(e) => { e.stopPropagation(); if (suppressEventClick) { suppressEventClick = false; return; } oneventclick?.(p.ev); }}
440
- onpointerenter={() => oneventhover?.(p.ev)}
441
- aria-label="{p.ev.title}{p.ev.status === 'cancelled' ? ' (cancelled)' : ''}{p.ev.status === 'tentative' ? ' (tentative)' : ''}{p.ev.status === 'full' ? ' (full)' : ''}{p.ev.status === 'limited' ? ' (limited)' : ''}{p.isCurrent ? `, ${L.inProgress}` : ''}{p.isNext ? `, ${L.upNext}` : ''}"
442
- >
443
- <div class="mb-ev-stripe"></div>
444
- <div class="mb-ev-body">
445
- <EventContent event={p.ev}>
446
- <span class="mb-ev-title">{p.ev.title}</span>
447
- {#if p.height > 32}
448
- <span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
407
+ <!-- All-day events bar -->
408
+ {#if allDayEvents.length > 0}
409
+ <div class="mb-allday" class:mb-allday--expanded={allDayExpanded}>
410
+ {#each visibleAllDay as seg (seg.ev.id)}
411
+ <button
412
+ type="button"
413
+ class="mb-allday-chip"
414
+ class:mb-allday-chip--selected={selectedEventId === seg.ev.id}
415
+ style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
416
+ onclick={() => oneventclick?.(seg.ev)}
417
+ >
418
+ <span class="mb-allday-dot"></span>
419
+ <span class="mb-allday-title">{seg.ev.title}</span>
420
+ {#if seg.totalDays > 1}
421
+ <span class="mb-allday-span">{seg.dayIndex}/{seg.totalDays}</span>
449
422
  {/if}
450
- {#if p.ev.subtitle && p.height > 48}
451
- <span class="mb-ev-sub">{p.ev.subtitle}</span>
423
+ </button>
424
+ {/each}
425
+ {#if allDayEvents.length > ALLDAY_MAX}
426
+ <button
427
+ type="button"
428
+ class="mb-allday-more"
429
+ aria-expanded={allDayExpanded}
430
+ onclick={() => { allDayExpanded = !allDayExpanded; }}
431
+ >
432
+ {allDayExpanded ? L.showLess : L.nMore(allDayEvents.length - ALLDAY_MAX)}
433
+ </button>
434
+ {/if}
435
+ </div>
436
+ {/if}
437
+
438
+ <!-- Scrollable time grid -->
439
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
440
+ <div
441
+ class="mb-grid"
442
+ bind:this={gridEl}
443
+ onclick={handleGridClick}
444
+ onpointerdown={onGridPointerDown}
445
+ onkeydown={onGridKeydown}
446
+ oncontextmenu={onGridContextMenu}
447
+ role="region"
448
+ aria-label={L.scrollableDayPlanner}
449
+ tabindex="0"
450
+ >
451
+ <div class="mb-grid-inner" style:height="{gridHeight}px">
452
+ <!-- Hour lanes -->
453
+ {#each { length: hourCount } as _, h}
454
+ {@const hour = startHour + h}
455
+ {@const blocked = isBlockedAt(hour)}
456
+ <div
457
+ class="mb-hour"
458
+ class:mb-hour--blocked={blocked}
459
+ style:top="{h * HOUR_HEIGHT}px"
460
+ style:height="{HOUR_HEIGHT}px"
461
+ >
462
+ <div class="mb-hour-label">{fmtH(hour, locale)}</div>
463
+ <div class="mb-hour-line"></div>
464
+ {#if blocked && blockedSlots}
465
+ {@const slot = blockedSlots.find(s => (!s.day || s.day === (new Date(dayMs).getDay() === 0 ? 7 : new Date(dayMs).getDay())) && hour >= s.start && hour < s.end)}
466
+ {#if slot?.label}
467
+ <span class="mb-blocked-label">{slot.label}</span>
468
+ {/if}
452
469
  {/if}
453
- {#if p.ev.location && p.height > 56}
454
- <span class="mb-ev-loc">{p.ev.location}</span>
470
+ </div>
471
+ {/each}
472
+
473
+ <!-- Now line -->
474
+ {#if nowOffset >= 0}
475
+ <div class="mb-now" style:top="{nowOffset}px">
476
+ <span class="mb-now-label">{clock.hm}</span>
477
+ <div class="mb-now-line"></div>
478
+ </div>
479
+ {/if}
480
+
481
+ <!-- Events -->
482
+ {#each positionedEvents as p (p.ev.id)}
483
+ <button
484
+ type="button"
485
+ class="mb-event"
486
+ class:mb-event--selected={selectedEventId === p.ev.id}
487
+ class:mb-event--current={p.isCurrent}
488
+ class:mb-event--next={p.isNext}
489
+ class:mb-event--cancelled={p.ev.status === 'cancelled'}
490
+ class:mb-event--tentative={p.ev.status === 'tentative'}
491
+ class:mb-event--full={p.ev.status === 'full'}
492
+ class:mb-event--limited={p.ev.status === 'limited'}
493
+ class:mb-event--resizing={p.isResizing}
494
+ class:mb-event--short={p.height < 44}
495
+ style:top="{p.top}px"
496
+ style:height="{p.height}px"
497
+ style:left={p.left}
498
+ style:width={p.width}
499
+ style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
500
+ onclick={(e) => { e.stopPropagation(); if (suppressEventClick) { suppressEventClick = false; return; } oneventclick?.(p.ev); }}
501
+ onpointerenter={() => oneventhover?.(p.ev)}
502
+ aria-label="{p.ev.title}{statusText(p.ev)}, {fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}{p.isCurrent ? `, ${L.inProgress}` : ''}{p.isNext ? `, ${L.upNext}` : ''}"
503
+ >
504
+ <div class="mb-ev-stripe"></div>
505
+ <div class="mb-ev-body">
506
+ <EventContent event={p.ev}>
507
+ <span class="mb-ev-title">{p.ev.title}</span>
508
+ {#if p.height > 32}
509
+ <span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
510
+ {/if}
511
+ {#if p.ev.subtitle && p.height > 48}
512
+ <span class="mb-ev-sub">{p.ev.subtitle}</span>
513
+ {/if}
514
+ {#if p.ev.location && p.height > 56}
515
+ <span class="mb-ev-loc">{p.ev.location}</span>
516
+ {/if}
517
+ {#if p.ev.tags?.length && p.height > 56}
518
+ <div class="mb-ev-tags">
519
+ {#each p.ev.tags as tag}
520
+ <span class="mb-ev-tag">{tag}</span>
521
+ {/each}
522
+ </div>
523
+ {/if}
524
+ </EventContent>
525
+ </div>
526
+ {#if p.isCurrent}
527
+ <span class="mb-ev-live"></span>
528
+ {:else if p.isNext}
529
+ <span class="mb-ev-next-badge">{L.upNext}</span>
455
530
  {/if}
456
- {#if p.ev.tags?.length && p.height > 56}
457
- <div class="mb-ev-tags">
458
- {#each p.ev.tags as tag}
459
- <span class="mb-ev-tag">{tag}</span>
460
- {/each}
461
- </div>
531
+ {#if !readOnly && !p.ev.data?.readOnly}
532
+ <span
533
+ class="mb-ev-handle mb-ev-handle--start"
534
+ aria-hidden="true"
535
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
536
+ ></span>
537
+ <span
538
+ class="mb-ev-handle mb-ev-handle--end"
539
+ aria-hidden="true"
540
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
541
+ ></span>
462
542
  {/if}
463
- </EventContent>
543
+ </button>
544
+ {/each}
545
+
546
+ <!-- Drag-to-create ghost -->
547
+ {#if !readOnly && drag?.active && drag.mode === 'create' && drag.payload}
548
+ {@const gTop = ((drag.payload.start.getTime() - dayMs) / HOUR_MS - startHour) * HOUR_HEIGHT}
549
+ {@const gH = Math.max(12, ((drag.payload.end.getTime() - drag.payload.start.getTime()) / HOUR_MS) * HOUR_HEIGHT)}
550
+ <div class="mb-create-ghost" style:top="{gTop}px" style:height="{gH}px" aria-hidden="true">
551
+ <span class="mb-create-ghost-time">
552
+ {fmtTime(drag.payload.start, locale)} – {fmtTime(drag.payload.end, locale)}
553
+ </span>
464
554
  </div>
465
- {#if p.isCurrent}
466
- <span class="mb-ev-live"></span>
467
- {:else if p.isNext}
468
- <span class="mb-ev-next-badge">{L.upNext}</span>
469
- {/if}
470
- {#if !readOnly && !p.ev.data?.readOnly}
471
- <span
472
- class="mb-ev-handle mb-ev-handle--start"
473
- aria-hidden="true"
474
- onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
475
- ></span>
476
- <span
477
- class="mb-ev-handle mb-ev-handle--end"
478
- aria-hidden="true"
479
- onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
480
- ></span>
481
- {/if}
482
- </button>
483
- {/each}
484
-
485
- <!-- Drag-to-create ghost -->
486
- {#if !readOnly && drag?.active && drag.mode === 'create' && drag.payload}
487
- {@const gTop = ((drag.payload.start.getTime() - dayMs) / HOUR_MS - startHour) * HOUR_HEIGHT}
488
- {@const gH = Math.max(12, ((drag.payload.end.getTime() - drag.payload.start.getTime()) / HOUR_MS) * HOUR_HEIGHT)}
489
- <div class="mb-create-ghost" style:top="{gTop}px" style:height="{gH}px" aria-hidden="true">
490
- <span class="mb-create-ghost-time">
491
- {fmtTime(drag.payload.start, locale)} – {fmtTime(drag.payload.end, locale)}
492
- </span>
493
- </div>
494
- {/if}
555
+ {/if}
556
+ </div>
495
557
  </div>
558
+
559
+ <!-- Empty state — overlaid, taps pass through to the grid -->
560
+ {#if isEmpty}
561
+ <div class="mb-empty">
562
+ {#if ctx.emptySnippet}
563
+ {@render ctx.emptySnippet()}
564
+ {:else}
565
+ <span class="mb-empty-text">{L.nothingScheduled}</span>
566
+ {/if}
567
+ </div>
568
+ {/if}
496
569
  </div>
497
570
  </div>
498
571
 
@@ -507,9 +580,25 @@ onMount(() => {
507
580
  overflow: hidden;
508
581
  background: var(--dt-bg, #fff);
509
582
  -webkit-tap-highlight-color: transparent;
583
+ touch-action: pan-y;
510
584
  }
511
585
  .mb--auto { overflow: visible; }
512
586
 
587
+ /* ─── Swipe wrapper (follows the finger) ─────────── */
588
+ .mb-swipe {
589
+ flex: 1;
590
+ min-height: 0;
591
+ display: flex;
592
+ flex-direction: column;
593
+ position: relative;
594
+ }
595
+ .mb-swipe--animate {
596
+ transition: transform 180ms ease;
597
+ }
598
+ @media (prefers-reduced-motion: reduce) {
599
+ .mb-swipe--animate { transition: none; }
600
+ }
601
+
513
602
  /* ─── All-day bar ────────────────────────────────── */
514
603
  .mb-allday {
515
604
  display: flex;
@@ -522,12 +611,17 @@ onMount(() => {
522
611
  align-items: center;
523
612
  }
524
613
  .mb-allday::-webkit-scrollbar { display: none; }
614
+ .mb-allday--expanded {
615
+ flex-wrap: wrap;
616
+ overflow-x: visible;
617
+ }
525
618
 
526
619
  .mb-allday-chip {
527
620
  display: flex;
528
621
  align-items: center;
529
622
  gap: 4px;
530
623
  padding: 4px 8px;
624
+ min-height: 32px;
531
625
  border-radius: 5px;
532
626
  background: color-mix(in srgb, var(--ev-color) 12%, var(--dt-surface, #f9fafb));
533
627
  border: none;
@@ -536,6 +630,17 @@ onMount(() => {
536
630
  transition: background 120ms;
537
631
  -webkit-tap-highlight-color: transparent;
538
632
  max-width: 160px;
633
+ position: relative;
634
+ }
635
+ /* Hit-slop: 44px effective touch target */
636
+ .mb-allday-chip::before {
637
+ content: '';
638
+ position: absolute;
639
+ left: 0;
640
+ right: 0;
641
+ top: 50%;
642
+ transform: translateY(-50%);
643
+ height: 44px;
539
644
  }
540
645
  .mb-allday-chip:active {
541
646
  background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #f9fafb));
@@ -543,6 +648,10 @@ onMount(() => {
543
648
  .mb-allday-chip--selected {
544
649
  box-shadow: 0 0 0 1.5px var(--ev-color);
545
650
  }
651
+ .mb-allday-chip:focus-visible {
652
+ outline: none;
653
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
654
+ }
546
655
 
547
656
  .mb-allday-dot {
548
657
  width: 6px;
@@ -553,7 +662,7 @@ onMount(() => {
553
662
  }
554
663
 
555
664
  .mb-allday-title {
556
- font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
665
+ font: 500 12px/1 var(--dt-sans, system-ui, sans-serif);
557
666
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
558
667
  white-space: nowrap;
559
668
  max-width: 100px;
@@ -562,16 +671,35 @@ onMount(() => {
562
671
  }
563
672
 
564
673
  .mb-allday-span {
565
- font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
566
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
674
+ font: 400 11px/1 var(--dt-sans, system-ui, sans-serif);
675
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
567
676
  }
568
677
 
569
678
  .mb-allday-more {
570
- font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
679
+ font: 500 12px/1 var(--dt-sans, system-ui, sans-serif);
571
680
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
572
681
  white-space: nowrap;
573
682
  flex-shrink: 0;
574
- padding: 0 4px;
683
+ padding: 0 6px;
684
+ min-height: 32px;
685
+ border: none;
686
+ background: transparent;
687
+ cursor: pointer;
688
+ position: relative;
689
+ -webkit-tap-highlight-color: transparent;
690
+ }
691
+ .mb-allday-more::before {
692
+ content: '';
693
+ position: absolute;
694
+ left: 0;
695
+ right: 0;
696
+ top: 50%;
697
+ transform: translateY(-50%);
698
+ height: 44px;
699
+ }
700
+ .mb-allday-more:focus-visible {
701
+ outline: none;
702
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
575
703
  }
576
704
 
577
705
  /* ─── Grid ───────────────────────────────────────── */
@@ -579,6 +707,7 @@ onMount(() => {
579
707
  flex: 1;
580
708
  overflow-y: auto;
581
709
  overflow-x: hidden;
710
+ overscroll-behavior: contain;
582
711
  -webkit-overflow-scrolling: touch;
583
712
  scrollbar-width: thin;
584
713
  scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.1)) transparent;
@@ -586,12 +715,31 @@ onMount(() => {
586
715
  padding-top: 8px;
587
716
  }
588
717
  .mb--auto .mb-grid { overflow-y: visible; }
718
+ .mb-grid:focus-visible {
719
+ outline: none;
720
+ box-shadow: inset 0 0 0 2px var(--dt-accent, #2563eb);
721
+ }
589
722
 
590
723
  .mb-grid-inner {
591
724
  position: relative;
592
725
  min-width: 100%;
593
726
  }
594
727
 
728
+ /* ─── Empty state ────────────────────────────────── */
729
+ .mb-empty {
730
+ position: absolute;
731
+ inset: 0;
732
+ display: flex;
733
+ align-items: center;
734
+ justify-content: center;
735
+ pointer-events: none;
736
+ z-index: 4;
737
+ }
738
+ .mb-empty-text {
739
+ font: 500 13px/1.4 var(--dt-sans, system-ui, sans-serif);
740
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
741
+ }
742
+
595
743
  /* ─── Hour row ───────────────────────────────────── */
596
744
  .mb-hour {
597
745
  position: absolute;
@@ -634,8 +782,8 @@ onMount(() => {
634
782
  left: 44px;
635
783
  top: 50%;
636
784
  transform: translateY(-50%);
637
- font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
638
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
785
+ font: 500 10px/1 var(--dt-sans, system-ui, sans-serif);
786
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
639
787
  text-transform: uppercase;
640
788
  letter-spacing: 0.04em;
641
789
  }
@@ -699,6 +847,24 @@ onMount(() => {
699
847
  .mb-event:active {
700
848
  background: color-mix(in srgb, var(--ev-color) 20%, var(--dt-surface, #f9fafb));
701
849
  }
850
+ /* Short blocks keep their duration-proportional height, but get a 44px
851
+ transparent hit-slop so taps still land. */
852
+ .mb-event--short {
853
+ overflow: visible;
854
+ }
855
+ .mb-event--short::after {
856
+ content: '';
857
+ position: absolute;
858
+ left: 0;
859
+ right: 0;
860
+ top: 50%;
861
+ transform: translateY(-50%);
862
+ height: 44px;
863
+ }
864
+ .mb-event--short .mb-ev-body {
865
+ padding-top: 2px;
866
+ padding-bottom: 2px;
867
+ }
702
868
  .mb-event--selected {
703
869
  box-shadow: 0 0 0 2px var(--ev-color),
704
870
  0 2px 12px color-mix(in srgb, var(--ev-color) 25%, transparent);
@@ -710,22 +876,32 @@ onMount(() => {
710
876
  background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, #f9fafb));
711
877
  border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
712
878
  }
879
+ /* Status treatments: token-level dims + a non-opacity signal
880
+ (strikethrough / border style) — never a bare opacity on the block. */
713
881
  .mb-event--cancelled {
714
- opacity: 0.5;
882
+ background: color-mix(in srgb, var(--ev-color) 5%, var(--dt-surface, #f9fafb));
715
883
  }
716
884
  .mb-event--cancelled .mb-ev-title {
717
885
  text-decoration: line-through;
886
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
887
+ }
888
+ .mb-event--cancelled .mb-ev-stripe {
889
+ opacity: 0.45; /* decorative bar only */
718
890
  }
719
891
  .mb-event--tentative {
720
- opacity: 0.65;
721
- border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
892
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, #f9fafb));
893
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
722
894
  }
723
895
  .mb-event--full {
724
- opacity: 0.55;
896
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, #f9fafb));
897
+ border: 1px solid color-mix(in srgb, var(--ev-color) 30%, transparent);
898
+ }
899
+ .mb-event--full .mb-ev-title {
900
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
725
901
  }
726
902
  .mb-event--limited {
727
- opacity: 0.65;
728
- border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
903
+ background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, #f9fafb));
904
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
729
905
  }
730
906
  .mb-event--resizing {
731
907
  z-index: 50;
@@ -745,6 +921,17 @@ onMount(() => {
745
921
  }
746
922
  .mb-ev-handle--start { top: 0; }
747
923
  .mb-ev-handle--end { bottom: 0; }
924
+ /* Hit-slop: ≥24px effective, extending inward so the block's
925
+ overflow clipping can't cut it off. */
926
+ .mb-ev-handle::before {
927
+ content: '';
928
+ position: absolute;
929
+ left: 0;
930
+ right: 0;
931
+ height: 24px;
932
+ }
933
+ .mb-ev-handle--start::before { top: 0; }
934
+ .mb-ev-handle--end::before { bottom: 0; }
748
935
  .mb-ev-handle::after {
749
936
  content: '';
750
937
  position: absolute;
@@ -760,8 +947,13 @@ onMount(() => {
760
947
  .mb-ev-handle--start::after { top: 2px; }
761
948
  .mb-ev-handle--end::after { bottom: 2px; }
762
949
  .mb-event:hover .mb-ev-handle::after,
950
+ .mb-event:focus-within .mb-ev-handle::after,
763
951
  .mb-event--resizing .mb-ev-handle::after,
764
952
  .mb-event--selected .mb-ev-handle::after { opacity: 0.55; }
953
+ /* Touch devices have no hover — show the handles persistently. */
954
+ @media (hover: none) {
955
+ .mb-ev-handle::after { opacity: 0.55; }
956
+ }
765
957
 
766
958
  /* ─── Drag-to-create ghost ───────────────────────── */
767
959
  .mb-create-ghost {
@@ -778,7 +970,7 @@ onMount(() => {
778
970
  pointer-events: none;
779
971
  }
780
972
  .mb-create-ghost-time {
781
- font: 600 10px/1 var(--dt-mono, ui-monospace, monospace);
973
+ font: 600 11px/1 var(--dt-mono, ui-monospace, monospace);
782
974
  color: var(--dt-accent, #2563eb);
783
975
  padding: 4px 8px;
784
976
  white-space: nowrap;
@@ -802,7 +994,7 @@ onMount(() => {
802
994
  }
803
995
 
804
996
  .mb-ev-title {
805
- font: 600 13px/1.2 var(--dt-sans, system-ui, sans-serif);
997
+ font: 600 15px/1.2 var(--dt-sans, system-ui, sans-serif);
806
998
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
807
999
  white-space: nowrap;
808
1000
  overflow: hidden;
@@ -810,21 +1002,21 @@ onMount(() => {
810
1002
  }
811
1003
 
812
1004
  .mb-ev-time {
813
- font: 400 11px/1 var(--dt-mono, ui-monospace, monospace);
1005
+ font: 400 12px/1 var(--dt-mono, ui-monospace, monospace);
814
1006
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
815
1007
  }
816
1008
 
817
1009
  .mb-ev-sub {
818
- font: 400 11px/1.1 var(--dt-sans, system-ui, sans-serif);
819
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1010
+ font: 400 12px/1.1 var(--dt-sans, system-ui, sans-serif);
1011
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
820
1012
  white-space: nowrap;
821
1013
  overflow: hidden;
822
1014
  text-overflow: ellipsis;
823
1015
  }
824
1016
 
825
1017
  .mb-ev-loc {
826
- font: 400 10px/1 var(--dt-sans, system-ui, sans-serif);
827
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
1018
+ font: 400 11px/1 var(--dt-sans, system-ui, sans-serif);
1019
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
828
1020
  white-space: nowrap;
829
1021
  overflow: hidden;
830
1022
  text-overflow: ellipsis;
@@ -837,7 +1029,7 @@ onMount(() => {
837
1029
  }
838
1030
 
839
1031
  .mb-ev-tag {
840
- font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
1032
+ font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
841
1033
  color: var(--ev-color, var(--dt-accent));
842
1034
  background: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 15%, transparent);
843
1035
  padding: 2px 5px;
@@ -855,11 +1047,14 @@ onMount(() => {
855
1047
  background: var(--ev-color, var(--dt-accent));
856
1048
  animation: mb-pulse 2s ease-in-out infinite;
857
1049
  }
1050
+ @media (prefers-reduced-motion: reduce) {
1051
+ .mb-ev-live { animation: none; }
1052
+ }
858
1053
  .mb-ev-next-badge {
859
1054
  position: absolute;
860
1055
  top: 4px;
861
1056
  right: 4px;
862
- font: 600 8px/1 var(--dt-sans, system-ui, sans-serif);
1057
+ font: 600 10px/1 var(--dt-sans, system-ui, sans-serif);
863
1058
  text-transform: uppercase;
864
1059
  letter-spacing: 0.06em;
865
1060
  color: var(--ev-color, var(--dt-accent));
@@ -876,7 +1071,7 @@ onMount(() => {
876
1071
 
877
1072
  /* ─── Focus ──────────────────────────────────────── */
878
1073
  .mb-event:focus-visible {
879
- outline: 2px solid var(--dt-accent, #2563eb);
880
- outline-offset: 2px;
1074
+ outline: none;
1075
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
881
1076
  }
882
1077
  </style>