@nomideusz/svelte-calendar 0.9.0 → 0.11.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.
@@ -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);
@@ -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,181 @@ 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}
401
+ ontouchcancel={swipe.ontouchcancel}
358
402
  >
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
403
  <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"
404
+ class="mb-swipe"
405
+ class:mb-swipe--animate={swipeAnimate}
406
+ style:transform={swipeOffset !== 0 ? `translateX(${swipeOffset}px)` : undefined}
391
407
  >
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>
408
+ <!-- All-day events bar -->
409
+ {#if allDayEvents.length > 0}
410
+ <div class="mb-allday" class:mb-allday--expanded={allDayExpanded}>
411
+ {#each visibleAllDay as seg (seg.ev.id)}
412
+ <button
413
+ type="button"
414
+ class="mb-allday-chip"
415
+ class:mb-allday-chip--selected={selectedEventId === seg.ev.id}
416
+ style:--ev-color={seg.ev.color ?? 'var(--dt-accent)'}
417
+ onclick={() => oneventclick?.(seg.ev)}
418
+ >
419
+ <span class="mb-allday-dot"></span>
420
+ <span class="mb-allday-title">{seg.ev.title}</span>
421
+ {#if seg.totalDays > 1}
422
+ <span class="mb-allday-span">{seg.dayIndex}/{seg.totalDays}</span>
449
423
  {/if}
450
- {#if p.ev.subtitle && p.height > 48}
451
- <span class="mb-ev-sub">{p.ev.subtitle}</span>
424
+ </button>
425
+ {/each}
426
+ {#if allDayEvents.length > ALLDAY_MAX}
427
+ <button
428
+ type="button"
429
+ class="mb-allday-more"
430
+ aria-expanded={allDayExpanded}
431
+ onclick={() => { allDayExpanded = !allDayExpanded; }}
432
+ >
433
+ {allDayExpanded ? L.showLess : L.nMore(allDayEvents.length - ALLDAY_MAX)}
434
+ </button>
435
+ {/if}
436
+ </div>
437
+ {/if}
438
+
439
+ <!-- Scrollable time grid -->
440
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
441
+ <div
442
+ class="mb-grid"
443
+ bind:this={gridEl}
444
+ onclick={handleGridClick}
445
+ onpointerdown={onGridPointerDown}
446
+ onkeydown={onGridKeydown}
447
+ oncontextmenu={onGridContextMenu}
448
+ role="region"
449
+ aria-label={L.scrollableDayPlanner}
450
+ tabindex="0"
451
+ >
452
+ <div class="mb-grid-inner" style:height="{gridHeight}px">
453
+ <!-- Hour lanes -->
454
+ {#each { length: hourCount } as _, h}
455
+ {@const hour = startHour + h}
456
+ {@const blocked = isBlockedAt(hour)}
457
+ <div
458
+ class="mb-hour"
459
+ class:mb-hour--blocked={blocked}
460
+ style:top="{h * HOUR_HEIGHT}px"
461
+ style:height="{HOUR_HEIGHT}px"
462
+ >
463
+ <div class="mb-hour-label">{fmtH(hour, locale)}</div>
464
+ <div class="mb-hour-line"></div>
465
+ {#if blocked && blockedSlots}
466
+ {@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)}
467
+ {#if slot?.label}
468
+ <span class="mb-blocked-label">{slot.label}</span>
469
+ {/if}
452
470
  {/if}
453
- {#if p.ev.location && p.height > 56}
454
- <span class="mb-ev-loc">{p.ev.location}</span>
471
+ </div>
472
+ {/each}
473
+
474
+ <!-- Now line -->
475
+ {#if nowOffset >= 0}
476
+ <div class="mb-now" style:top="{nowOffset}px">
477
+ <span class="mb-now-label">{clock.hm}</span>
478
+ <div class="mb-now-line"></div>
479
+ </div>
480
+ {/if}
481
+
482
+ <!-- Events -->
483
+ {#each positionedEvents as p (p.ev.id)}
484
+ <button
485
+ type="button"
486
+ class="mb-event"
487
+ class:mb-event--selected={selectedEventId === p.ev.id}
488
+ class:mb-event--current={p.isCurrent}
489
+ class:mb-event--next={p.isNext}
490
+ class:mb-event--cancelled={p.ev.status === 'cancelled'}
491
+ class:mb-event--tentative={p.ev.status === 'tentative'}
492
+ class:mb-event--full={p.ev.status === 'full'}
493
+ class:mb-event--limited={p.ev.status === 'limited'}
494
+ class:mb-event--resizing={p.isResizing}
495
+ class:mb-event--short={p.height < 44}
496
+ style:top="{p.top}px"
497
+ style:height="{p.height}px"
498
+ style:left={p.left}
499
+ style:width={p.width}
500
+ style:--ev-color={p.ev.color ?? 'var(--dt-accent)'}
501
+ onclick={(e) => { e.stopPropagation(); if (suppressEventClick) { suppressEventClick = false; return; } oneventclick?.(p.ev); }}
502
+ onpointerenter={() => oneventhover?.(p.ev)}
503
+ 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}` : ''}"
504
+ >
505
+ <div class="mb-ev-stripe"></div>
506
+ <div class="mb-ev-body">
507
+ <EventContent event={p.ev}>
508
+ <!-- Line thresholds must track real content height (title 18 +
509
+ time 13 + sub 14 + loc 12 + tags 19 + padding 8) — showing
510
+ a line the block can't hold clips text at both ends. -->
511
+ <span class="mb-ev-title">{p.ev.title}</span>
512
+ {#if p.height > 32}
513
+ <span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
514
+ {/if}
515
+ {#if p.ev.subtitle && p.height > 56}
516
+ <span class="mb-ev-sub">{p.ev.subtitle}</span>
517
+ {/if}
518
+ {#if p.ev.location && p.height > 72}
519
+ <span class="mb-ev-loc">{p.ev.location}</span>
520
+ {/if}
521
+ {#if p.ev.tags?.length && p.height > 88}
522
+ <div class="mb-ev-tags">
523
+ {#each p.ev.tags as tag}
524
+ <span class="mb-ev-tag">{tag}</span>
525
+ {/each}
526
+ </div>
527
+ {/if}
528
+ </EventContent>
529
+ </div>
530
+ {#if p.isCurrent}
531
+ <span class="mb-ev-live"></span>
532
+ {:else if p.isNext}
533
+ <span class="mb-ev-next-badge">{L.upNext}</span>
455
534
  {/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>
535
+ {#if !readOnly && !p.ev.data?.readOnly}
536
+ <span
537
+ class="mb-ev-handle mb-ev-handle--start"
538
+ aria-hidden="true"
539
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'start')}
540
+ ></span>
541
+ <span
542
+ class="mb-ev-handle mb-ev-handle--end"
543
+ aria-hidden="true"
544
+ onpointerdown={(e) => onResizePointerDown(e, p.ev, 'end')}
545
+ ></span>
462
546
  {/if}
463
- </EventContent>
547
+ </button>
548
+ {/each}
549
+
550
+ <!-- Drag-to-create ghost -->
551
+ {#if !readOnly && drag?.active && drag.mode === 'create' && drag.payload}
552
+ {@const gTop = ((drag.payload.start.getTime() - dayMs) / HOUR_MS - startHour) * HOUR_HEIGHT}
553
+ {@const gH = Math.max(12, ((drag.payload.end.getTime() - drag.payload.start.getTime()) / HOUR_MS) * HOUR_HEIGHT)}
554
+ <div class="mb-create-ghost" style:top="{gTop}px" style:height="{gH}px" aria-hidden="true">
555
+ <span class="mb-create-ghost-time">
556
+ {fmtTime(drag.payload.start, locale)} – {fmtTime(drag.payload.end, locale)}
557
+ </span>
464
558
  </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}
559
+ {/if}
560
+ </div>
495
561
  </div>
562
+
563
+ <!-- Empty state — overlaid, taps pass through to the grid -->
564
+ {#if isEmpty}
565
+ <div class="mb-empty">
566
+ {#if ctx.emptySnippet}
567
+ {@render ctx.emptySnippet()}
568
+ {:else}
569
+ <span class="mb-empty-text">{L.nothingScheduled}</span>
570
+ {/if}
571
+ </div>
572
+ {/if}
496
573
  </div>
497
574
  </div>
498
575
 
@@ -507,9 +584,25 @@ onMount(() => {
507
584
  overflow: hidden;
508
585
  background: var(--dt-bg, #fff);
509
586
  -webkit-tap-highlight-color: transparent;
587
+ touch-action: pan-y;
510
588
  }
511
589
  .mb--auto { overflow: visible; }
512
590
 
591
+ /* ─── Swipe wrapper (follows the finger) ─────────── */
592
+ .mb-swipe {
593
+ flex: 1;
594
+ min-height: 0;
595
+ display: flex;
596
+ flex-direction: column;
597
+ position: relative;
598
+ }
599
+ .mb-swipe--animate {
600
+ transition: transform 180ms ease;
601
+ }
602
+ @media (prefers-reduced-motion: reduce) {
603
+ .mb-swipe--animate { transition: none; }
604
+ }
605
+
513
606
  /* ─── All-day bar ────────────────────────────────── */
514
607
  .mb-allday {
515
608
  display: flex;
@@ -522,12 +615,17 @@ onMount(() => {
522
615
  align-items: center;
523
616
  }
524
617
  .mb-allday::-webkit-scrollbar { display: none; }
618
+ .mb-allday--expanded {
619
+ flex-wrap: wrap;
620
+ overflow-x: visible;
621
+ }
525
622
 
526
623
  .mb-allday-chip {
527
624
  display: flex;
528
625
  align-items: center;
529
626
  gap: 4px;
530
627
  padding: 4px 8px;
628
+ min-height: 32px;
531
629
  border-radius: 5px;
532
630
  background: color-mix(in srgb, var(--ev-color) 12%, var(--dt-surface, #f9fafb));
533
631
  border: none;
@@ -536,6 +634,17 @@ onMount(() => {
536
634
  transition: background 120ms;
537
635
  -webkit-tap-highlight-color: transparent;
538
636
  max-width: 160px;
637
+ position: relative;
638
+ }
639
+ /* Hit-slop: 44px effective touch target */
640
+ .mb-allday-chip::before {
641
+ content: '';
642
+ position: absolute;
643
+ left: 0;
644
+ right: 0;
645
+ top: 50%;
646
+ transform: translateY(-50%);
647
+ height: 44px;
539
648
  }
540
649
  .mb-allday-chip:active {
541
650
  background: color-mix(in srgb, var(--ev-color) 22%, var(--dt-surface, #f9fafb));
@@ -543,6 +652,10 @@ onMount(() => {
543
652
  .mb-allday-chip--selected {
544
653
  box-shadow: 0 0 0 1.5px var(--ev-color);
545
654
  }
655
+ .mb-allday-chip:focus-visible {
656
+ outline: none;
657
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
658
+ }
546
659
 
547
660
  .mb-allday-dot {
548
661
  width: 6px;
@@ -553,7 +666,7 @@ onMount(() => {
553
666
  }
554
667
 
555
668
  .mb-allday-title {
556
- font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
669
+ font: 500 12px/1 var(--dt-sans, system-ui, sans-serif);
557
670
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
558
671
  white-space: nowrap;
559
672
  max-width: 100px;
@@ -562,16 +675,35 @@ onMount(() => {
562
675
  }
563
676
 
564
677
  .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));
678
+ font: 400 11px/1 var(--dt-sans, system-ui, sans-serif);
679
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
567
680
  }
568
681
 
569
682
  .mb-allday-more {
570
- font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
683
+ font: 500 12px/1 var(--dt-sans, system-ui, sans-serif);
571
684
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
572
685
  white-space: nowrap;
573
686
  flex-shrink: 0;
574
- padding: 0 4px;
687
+ padding: 0 6px;
688
+ min-height: 32px;
689
+ border: none;
690
+ background: transparent;
691
+ cursor: pointer;
692
+ position: relative;
693
+ -webkit-tap-highlight-color: transparent;
694
+ }
695
+ .mb-allday-more::before {
696
+ content: '';
697
+ position: absolute;
698
+ left: 0;
699
+ right: 0;
700
+ top: 50%;
701
+ transform: translateY(-50%);
702
+ height: 44px;
703
+ }
704
+ .mb-allday-more:focus-visible {
705
+ outline: none;
706
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
575
707
  }
576
708
 
577
709
  /* ─── Grid ───────────────────────────────────────── */
@@ -579,6 +711,7 @@ onMount(() => {
579
711
  flex: 1;
580
712
  overflow-y: auto;
581
713
  overflow-x: hidden;
714
+ overscroll-behavior: contain;
582
715
  -webkit-overflow-scrolling: touch;
583
716
  scrollbar-width: thin;
584
717
  scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.1)) transparent;
@@ -586,12 +719,31 @@ onMount(() => {
586
719
  padding-top: 8px;
587
720
  }
588
721
  .mb--auto .mb-grid { overflow-y: visible; }
722
+ .mb-grid:focus-visible {
723
+ outline: none;
724
+ box-shadow: inset 0 0 0 2px var(--dt-accent, #2563eb);
725
+ }
589
726
 
590
727
  .mb-grid-inner {
591
728
  position: relative;
592
729
  min-width: 100%;
593
730
  }
594
731
 
732
+ /* ─── Empty state ────────────────────────────────── */
733
+ .mb-empty {
734
+ position: absolute;
735
+ inset: 0;
736
+ display: flex;
737
+ align-items: center;
738
+ justify-content: center;
739
+ pointer-events: none;
740
+ z-index: 4;
741
+ }
742
+ .mb-empty-text {
743
+ font: 500 13px/1.4 var(--dt-sans, system-ui, sans-serif);
744
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
745
+ }
746
+
595
747
  /* ─── Hour row ───────────────────────────────────── */
596
748
  .mb-hour {
597
749
  position: absolute;
@@ -603,6 +755,9 @@ onMount(() => {
603
755
 
604
756
  .mb-hour-label {
605
757
  width: 40px;
758
+ /* border-box keeps the label inside the 40px gutter that events
759
+ start at — content-box pushed digits flush under the event edge */
760
+ box-sizing: border-box;
606
761
  flex-shrink: 0;
607
762
  font: 500 11px/1 var(--dt-mono, ui-monospace, monospace);
608
763
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
@@ -634,8 +789,8 @@ onMount(() => {
634
789
  left: 44px;
635
790
  top: 50%;
636
791
  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));
792
+ font: 500 10px/1 var(--dt-sans, system-ui, sans-serif);
793
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
639
794
  text-transform: uppercase;
640
795
  letter-spacing: 0.04em;
641
796
  }
@@ -653,6 +808,7 @@ onMount(() => {
653
808
 
654
809
  .mb-now-label {
655
810
  width: 40px;
811
+ box-sizing: border-box;
656
812
  flex-shrink: 0;
657
813
  text-align: right;
658
814
  padding-right: 6px;
@@ -699,6 +855,24 @@ onMount(() => {
699
855
  .mb-event:active {
700
856
  background: color-mix(in srgb, var(--ev-color) 20%, var(--dt-surface, #f9fafb));
701
857
  }
858
+ /* Short blocks keep their duration-proportional height, but get a 44px
859
+ transparent hit-slop so taps still land. */
860
+ .mb-event--short {
861
+ overflow: visible;
862
+ }
863
+ .mb-event--short::after {
864
+ content: '';
865
+ position: absolute;
866
+ left: 0;
867
+ right: 0;
868
+ top: 50%;
869
+ transform: translateY(-50%);
870
+ height: 44px;
871
+ }
872
+ .mb-event--short .mb-ev-body {
873
+ padding-top: 2px;
874
+ padding-bottom: 2px;
875
+ }
702
876
  .mb-event--selected {
703
877
  box-shadow: 0 0 0 2px var(--ev-color),
704
878
  0 2px 12px color-mix(in srgb, var(--ev-color) 25%, transparent);
@@ -710,22 +884,32 @@ onMount(() => {
710
884
  background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, #f9fafb));
711
885
  border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
712
886
  }
887
+ /* Status treatments: token-level dims + a non-opacity signal
888
+ (strikethrough / border style) — never a bare opacity on the block. */
713
889
  .mb-event--cancelled {
714
- opacity: 0.5;
890
+ background: color-mix(in srgb, var(--ev-color) 5%, var(--dt-surface, #f9fafb));
715
891
  }
716
892
  .mb-event--cancelled .mb-ev-title {
717
893
  text-decoration: line-through;
894
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
895
+ }
896
+ .mb-event--cancelled .mb-ev-stripe {
897
+ opacity: 0.45; /* decorative bar only */
718
898
  }
719
899
  .mb-event--tentative {
720
- opacity: 0.65;
721
- border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
900
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, #f9fafb));
901
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
722
902
  }
723
903
  .mb-event--full {
724
- opacity: 0.55;
904
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, #f9fafb));
905
+ border: 1px solid color-mix(in srgb, var(--ev-color) 30%, transparent);
906
+ }
907
+ .mb-event--full .mb-ev-title {
908
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
725
909
  }
726
910
  .mb-event--limited {
727
- opacity: 0.65;
728
- border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
911
+ background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, #f9fafb));
912
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
729
913
  }
730
914
  .mb-event--resizing {
731
915
  z-index: 50;
@@ -745,6 +929,17 @@ onMount(() => {
745
929
  }
746
930
  .mb-ev-handle--start { top: 0; }
747
931
  .mb-ev-handle--end { bottom: 0; }
932
+ /* Hit-slop: ≥24px effective, extending inward so the block's
933
+ overflow clipping can't cut it off. */
934
+ .mb-ev-handle::before {
935
+ content: '';
936
+ position: absolute;
937
+ left: 0;
938
+ right: 0;
939
+ height: 24px;
940
+ }
941
+ .mb-ev-handle--start::before { top: 0; }
942
+ .mb-ev-handle--end::before { bottom: 0; }
748
943
  .mb-ev-handle::after {
749
944
  content: '';
750
945
  position: absolute;
@@ -760,8 +955,13 @@ onMount(() => {
760
955
  .mb-ev-handle--start::after { top: 2px; }
761
956
  .mb-ev-handle--end::after { bottom: 2px; }
762
957
  .mb-event:hover .mb-ev-handle::after,
958
+ .mb-event:focus-within .mb-ev-handle::after,
763
959
  .mb-event--resizing .mb-ev-handle::after,
764
960
  .mb-event--selected .mb-ev-handle::after { opacity: 0.55; }
961
+ /* Touch devices have no hover — show the handles persistently. */
962
+ @media (hover: none) {
963
+ .mb-ev-handle::after { opacity: 0.55; }
964
+ }
765
965
 
766
966
  /* ─── Drag-to-create ghost ───────────────────────── */
767
967
  .mb-create-ghost {
@@ -778,7 +978,7 @@ onMount(() => {
778
978
  pointer-events: none;
779
979
  }
780
980
  .mb-create-ghost-time {
781
- font: 600 10px/1 var(--dt-mono, ui-monospace, monospace);
981
+ font: 600 11px/1 var(--dt-mono, ui-monospace, monospace);
782
982
  color: var(--dt-accent, #2563eb);
783
983
  padding: 4px 8px;
784
984
  white-space: nowrap;
@@ -802,7 +1002,7 @@ onMount(() => {
802
1002
  }
803
1003
 
804
1004
  .mb-ev-title {
805
- font: 600 13px/1.2 var(--dt-sans, system-ui, sans-serif);
1005
+ font: 600 15px/1.2 var(--dt-sans, system-ui, sans-serif);
806
1006
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
807
1007
  white-space: nowrap;
808
1008
  overflow: hidden;
@@ -810,21 +1010,21 @@ onMount(() => {
810
1010
  }
811
1011
 
812
1012
  .mb-ev-time {
813
- font: 400 11px/1 var(--dt-mono, ui-monospace, monospace);
1013
+ font: 400 12px/1 var(--dt-mono, ui-monospace, monospace);
814
1014
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
815
1015
  }
816
1016
 
817
1017
  .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));
1018
+ font: 400 12px/1.1 var(--dt-sans, system-ui, sans-serif);
1019
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
820
1020
  white-space: nowrap;
821
1021
  overflow: hidden;
822
1022
  text-overflow: ellipsis;
823
1023
  }
824
1024
 
825
1025
  .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));
1026
+ font: 400 11px/1 var(--dt-sans, system-ui, sans-serif);
1027
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
828
1028
  white-space: nowrap;
829
1029
  overflow: hidden;
830
1030
  text-overflow: ellipsis;
@@ -837,7 +1037,7 @@ onMount(() => {
837
1037
  }
838
1038
 
839
1039
  .mb-ev-tag {
840
- font: 500 9px/1 var(--dt-sans, system-ui, sans-serif);
1040
+ font: 500 11px/1 var(--dt-sans, system-ui, sans-serif);
841
1041
  color: var(--ev-color, var(--dt-accent));
842
1042
  background: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 15%, transparent);
843
1043
  padding: 2px 5px;
@@ -855,11 +1055,14 @@ onMount(() => {
855
1055
  background: var(--ev-color, var(--dt-accent));
856
1056
  animation: mb-pulse 2s ease-in-out infinite;
857
1057
  }
1058
+ @media (prefers-reduced-motion: reduce) {
1059
+ .mb-ev-live { animation: none; }
1060
+ }
858
1061
  .mb-ev-next-badge {
859
1062
  position: absolute;
860
1063
  top: 4px;
861
1064
  right: 4px;
862
- font: 600 8px/1 var(--dt-sans, system-ui, sans-serif);
1065
+ font: 600 10px/1 var(--dt-sans, system-ui, sans-serif);
863
1066
  text-transform: uppercase;
864
1067
  letter-spacing: 0.06em;
865
1068
  color: var(--ev-color, var(--dt-accent));
@@ -876,7 +1079,7 @@ onMount(() => {
876
1079
 
877
1080
  /* ─── Focus ──────────────────────────────────────── */
878
1081
  .mb-event:focus-visible {
879
- outline: 2px solid var(--dt-accent, #2563eb);
880
- outline-offset: 2px;
1082
+ outline: none;
1083
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
881
1084
  }
882
1085
  </style>