@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.
@@ -10,8 +10,8 @@ import EventContent from "../shared/EventContent.svelte";
10
10
  import { createClock } from "../../core/clock.svelte.js";
11
11
  import { DAY_MS, sod, isAllDay, isMultiDay } from "../../core/time.js";
12
12
  import { startOfWeek as sowFn } from "../../core/time.js";
13
- import { fmtTime as _fmtTime, weekdayShort, getLabels } from "../../core/locale.js";
14
- const L = $derived(getLabels());
13
+ import { fmtTime as _fmtTime, weekdayShort } from "../../core/locale.js";
14
+ import { createSwipe } from "./swipe.js";
15
15
  let {
16
16
  mondayStart = true,
17
17
  locale,
@@ -20,11 +20,10 @@ let {
20
20
  style = "",
21
21
  focusDate,
22
22
  oneventclick,
23
- oneventcreate,
24
- selectedEventId = null,
25
- readOnly = false
23
+ selectedEventId = null
26
24
  } = $props();
27
25
  const ctx = useCalendarContext();
26
+ const L = $derived(ctx.labels);
28
27
  const viewState = $derived(ctx.viewState);
29
28
  const equalDays = $derived(ctx.equalDays);
30
29
  const showDates = $derived(ctx.showDates);
@@ -33,7 +32,6 @@ const autoHeight = $derived(ctx.autoHeight);
33
32
  const oneventhover = $derived(ctx.oneventhover);
34
33
  const disabledSet = $derived(ctx.disabledSet);
35
34
  const loadRangeCtx = $derived(ctx.loadRange);
36
- const minDuration = $derived(ctx.minDuration);
37
35
  const clock = createClock(ctx.timezone);
38
36
  const MAX_EVENTS = 3;
39
37
  const customDays = $derived(viewState?.dayCount ?? 7);
@@ -80,54 +78,53 @@ const dayCells = $derived.by(() => {
80
78
  }
81
79
  return result;
82
80
  });
81
+ let expandedDays = $state(/* @__PURE__ */ new Set());
82
+ function toggleExpand(ms) {
83
+ const next = new Set(expandedDays);
84
+ if (next.has(ms)) next.delete(ms);
85
+ else next.add(ms);
86
+ expandedDays = next;
87
+ }
83
88
  function fmtTime(d) {
84
89
  return _fmtTime(d, locale);
85
90
  }
86
- let touchStartX = 0;
87
- let touchStartY = 0;
88
- let swiping = false;
89
- let swipeOffset = $state(0);
90
- const SWIPE_THRESHOLD = 60;
91
- function onTouchStart(e) {
92
- const t = e.touches[0];
93
- touchStartX = t.clientX;
94
- touchStartY = t.clientY;
95
- swiping = true;
96
- swipeOffset = 0;
91
+ function evTimeLabel(ev) {
92
+ if (isAllDay(ev) || isMultiDay(ev)) return L.allDay;
93
+ return `${fmtTime(ev.start)} \u2013 ${fmtTime(ev.end)}`;
97
94
  }
98
- function onTouchMove(e) {
99
- if (!swiping) return;
100
- const t = e.touches[0];
101
- const dx = t.clientX - touchStartX;
102
- const dy = t.clientY - touchStartY;
103
- if (Math.abs(dy) > Math.abs(dx) * 0.8) {
104
- swiping = false;
105
- return;
106
- }
107
- swipeOffset = dx;
95
+ function statusText(ev) {
96
+ if (ev.status === "cancelled") return ` (${L.cancelled})`;
97
+ if (ev.status === "tentative") return ` (${L.tentative})`;
98
+ if (ev.status === "full") return ` (${L.full})`;
99
+ if (ev.status === "limited") return ` (${L.limited})`;
100
+ return "";
108
101
  }
109
- function onTouchEnd() {
110
- if (!swiping) {
111
- swipeOffset = 0;
112
- return;
113
- }
114
- if (Math.abs(swipeOffset) > SWIPE_THRESHOLD) {
115
- if (swipeOffset > 0) {
116
- viewState?.prev();
102
+ let swipeOffset = $state(0);
103
+ let swipeAnimate = $state(false);
104
+ const swipe = createSwipe({
105
+ onmove: (dx) => {
106
+ swipeAnimate = false;
107
+ swipeOffset = dx;
108
+ },
109
+ onend: (dir) => {
110
+ if (dir !== 0) {
111
+ swipeAnimate = false;
112
+ swipeOffset = 0;
113
+ if (dir > 0) viewState?.prev();
114
+ else viewState?.next();
117
115
  } else {
118
- viewState?.next();
116
+ swipeAnimate = true;
117
+ swipeOffset = 0;
119
118
  }
120
119
  }
121
- swipeOffset = 0;
122
- swiping = false;
123
- }
120
+ });
124
121
  function handleDayTap(dayMs) {
125
- if (viewState) {
126
- viewState.setFocusDate(new Date(dayMs));
127
- const currentView = viewState.view;
128
- const dayView = currentView.replace("week", "day");
129
- viewState.setView(dayView);
130
- }
122
+ if (!viewState) return;
123
+ viewState.setFocusDate(new Date(dayMs));
124
+ const currentView = viewState.view;
125
+ if (!currentView.split("-").includes("week")) return;
126
+ const dayView = currentView.split("-").map((seg) => seg === "week" ? "day" : seg).join("-");
127
+ if (dayView !== currentView) viewState.setView(dayView);
131
128
  }
132
129
  function handleDayKeydown(e, dayMs) {
133
130
  if (e.key !== "Enter" && e.key !== " ") return;
@@ -143,12 +140,18 @@ function handleDayKeydown(e, dayMs) {
143
140
  style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')}
144
141
  role="region"
145
142
  aria-label={L.weekAhead}
146
- ontouchstart={onTouchStart}
147
- ontouchmove={onTouchMove}
148
- ontouchend={onTouchEnd}
143
+ ontouchstart={swipe.ontouchstart}
144
+ ontouchmove={swipe.ontouchmove}
145
+ ontouchend={swipe.ontouchend}
146
+ ontouchcancel={swipe.ontouchcancel}
149
147
  >
150
148
  <!-- Vertical day list -->
151
- <div class="mw-list" role="list">
149
+ <div
150
+ class="mw-list"
151
+ class:mw-list--animate={swipeAnimate}
152
+ style:transform={swipeOffset !== 0 ? `translateX(${swipeOffset}px)` : undefined}
153
+ role="list"
154
+ >
152
155
  {#each dayCells as cell (cell.ms)}
153
156
  <div
154
157
  class="mw-row"
@@ -157,13 +160,15 @@ function handleDayKeydown(e, dayMs) {
157
160
  class:mw-row--weekend={cell.isWeekend}
158
161
  class:mw-row--disabled={cell.isDisabled}
159
162
  role="listitem"
163
+ aria-current={cell.isToday ? 'date' : undefined}
160
164
  >
161
165
  <button
166
+ type="button"
162
167
  class="mw-row-target"
163
168
  disabled={cell.isDisabled}
164
169
  onclick={() => handleDayTap(cell.ms)}
165
170
  onkeydown={(e) => handleDayKeydown(e, cell.ms)}
166
- aria-label="{cell.dayName} {cell.dayNum}"
171
+ aria-label="{cell.dayName} {cell.dayNum}{cell.isToday ? `, ${L.today}` : ''}"
167
172
  ></button>
168
173
  <!-- Date column -->
169
174
  <div class="mw-date">
@@ -178,7 +183,7 @@ function handleDayKeydown(e, dayMs) {
178
183
  {#if cell.events.length === 0}
179
184
  <span class="mw-empty">{L.noEvents}</span>
180
185
  {:else}
181
- {#each cell.events.slice(0, MAX_EVENTS) as ev (ev.id)}
186
+ {#each (expandedDays.has(cell.ms) ? cell.events : cell.events.slice(0, MAX_EVENTS)) as ev (ev.id)}
182
187
  <button
183
188
  type="button"
184
189
  class="mw-ev"
@@ -193,6 +198,7 @@ function handleDayKeydown(e, dayMs) {
193
198
  onclick={(e) => { e.stopPropagation(); oneventclick?.(ev); }}
194
199
  onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev); } }}
195
200
  onpointerenter={() => oneventhover?.(ev)}
201
+ aria-label="{ev.title}{statusText(ev)}, {evTimeLabel(ev)}"
196
202
  >
197
203
  <span class="mw-ev-stripe"></span>
198
204
  <div class="mw-ev-body">
@@ -208,7 +214,14 @@ function handleDayKeydown(e, dayMs) {
208
214
  </button>
209
215
  {/each}
210
216
  {#if cell.totalCount > MAX_EVENTS}
211
- <span class="mw-ev-more">{L.nMore(cell.totalCount - MAX_EVENTS)}</span>
217
+ <button
218
+ type="button"
219
+ class="mw-ev-more"
220
+ aria-expanded={expandedDays.has(cell.ms)}
221
+ onclick={() => toggleExpand(cell.ms)}
222
+ >
223
+ {expandedDays.has(cell.ms) ? L.showLess : L.nMore(cell.totalCount - MAX_EVENTS)}
224
+ </button>
212
225
  {/if}
213
226
  {/if}
214
227
  </div>
@@ -231,6 +244,7 @@ function handleDayKeydown(e, dayMs) {
231
244
  overflow: hidden;
232
245
  background: var(--dt-bg, #fff);
233
246
  -webkit-tap-highlight-color: transparent;
247
+ touch-action: pan-y;
234
248
  }
235
249
  .mw--auto { overflow: visible; }
236
250
 
@@ -238,11 +252,19 @@ function handleDayKeydown(e, dayMs) {
238
252
  .mw-list {
239
253
  flex: 1;
240
254
  overflow-y: auto;
255
+ overflow-x: hidden;
256
+ overscroll-behavior: contain;
241
257
  -webkit-overflow-scrolling: touch;
242
258
  scrollbar-width: thin;
243
259
  scrollbar-color: var(--dt-scrollbar, rgba(0, 0, 0, 0.1)) transparent;
244
260
  }
245
261
  .mw--auto .mw-list { overflow-y: visible; }
262
+ .mw-list--animate {
263
+ transition: transform 180ms ease;
264
+ }
265
+ @media (prefers-reduced-motion: reduce) {
266
+ .mw-list--animate { transition: none; }
267
+ }
246
268
 
247
269
  /* ─── Day row ────────────────────────────────────── */
248
270
  .mw-row {
@@ -250,6 +272,10 @@ function handleDayKeydown(e, dayMs) {
250
272
  align-items: center;
251
273
  gap: 12px;
252
274
  position: relative;
275
+ /* border-box: width 100% + padding otherwise overflows the list by
276
+ 24px, which iOS turns into a horizontal pan that clips the date
277
+ column off the left edge */
278
+ box-sizing: border-box;
253
279
  padding: 10px 12px;
254
280
  background: transparent;
255
281
  transition: background 120ms;
@@ -268,8 +294,13 @@ function handleDayKeydown(e, dayMs) {
268
294
  .mw-row--today {
269
295
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 4%, transparent);
270
296
  }
297
+ /* Token-based dim (not subtree opacity) so past rows stay legible/tappable */
271
298
  .mw-row--past {
272
- opacity: 0.5;
299
+ background: color-mix(in srgb, var(--dt-text, rgba(0, 0, 0, 0.87)) 3%, transparent);
300
+ }
301
+ .mw-row--past :global(.mw-ev-title),
302
+ .mw-row--past .mw-day-num {
303
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
273
304
  }
274
305
  .mw-row--disabled {
275
306
  background-image: repeating-linear-gradient(
@@ -294,8 +325,8 @@ function handleDayKeydown(e, dayMs) {
294
325
  cursor: default;
295
326
  }
296
327
  .mw-row-target:focus-visible {
297
- outline: 2px solid var(--dt-accent, #2563eb);
298
- outline-offset: -2px;
328
+ outline: none;
329
+ box-shadow: inset 0 0 0 2px var(--dt-accent, #2563eb);
299
330
  }
300
331
 
301
332
  /* ─── Date column ────────────────────────────────── */
@@ -312,10 +343,10 @@ function handleDayKeydown(e, dayMs) {
312
343
  }
313
344
 
314
345
  .mw-day-name {
315
- font: 600 10px/1 var(--dt-sans, system-ui, sans-serif);
346
+ font: 600 11px/1 var(--dt-sans, system-ui, sans-serif);
316
347
  letter-spacing: 0.06em;
317
348
  text-transform: uppercase;
318
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
349
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
319
350
  }
320
351
  .mw-day-name--today {
321
352
  color: var(--dt-accent, #2563eb);
@@ -338,6 +369,8 @@ function handleDayKeydown(e, dayMs) {
338
369
  }
339
370
 
340
371
  /* ─── Events column ──────────────────────────────── */
372
+ /* pointer-events pass through to the full-row target underneath;
373
+ only the chips (and "+N more") re-capture them. */
341
374
  .mw-events {
342
375
  flex: 1;
343
376
  min-width: 0;
@@ -346,12 +379,12 @@ function handleDayKeydown(e, dayMs) {
346
379
  gap: 4px;
347
380
  position: relative;
348
381
  z-index: 2;
382
+ pointer-events: none;
349
383
  }
350
384
 
351
385
  .mw-empty {
352
- font: 400 12px/1 var(--dt-sans, system-ui, sans-serif);
353
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
354
- font-style: italic;
386
+ font: 400 13px/1 var(--dt-sans, system-ui, sans-serif);
387
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
355
388
  }
356
389
 
357
390
  /* ─── Event chip ─────────────────────────────────── */
@@ -359,6 +392,7 @@ function handleDayKeydown(e, dayMs) {
359
392
  display: flex;
360
393
  align-items: center;
361
394
  gap: 0;
395
+ min-height: 44px;
362
396
  border-radius: 6px;
363
397
  background: color-mix(in srgb, var(--ev-color) 10%, var(--dt-surface, #f9fafb));
364
398
  overflow: hidden;
@@ -368,6 +402,7 @@ function handleDayKeydown(e, dayMs) {
368
402
  border: none;
369
403
  text-align: left;
370
404
  padding: 0;
405
+ pointer-events: auto;
371
406
  }
372
407
  .mw-ev:active {
373
408
  background: color-mix(in srgb, var(--ev-color) 20%, var(--dt-surface, #f9fafb));
@@ -381,22 +416,32 @@ function handleDayKeydown(e, dayMs) {
381
416
  .mw-ev--allday {
382
417
  background: color-mix(in srgb, var(--ev-color) 14%, var(--dt-surface, #f9fafb));
383
418
  }
419
+ /* Status treatments: token-level dims + a non-opacity signal
420
+ (strikethrough / border style) — never a bare opacity on the chip. */
384
421
  .mw-ev--cancelled {
385
- opacity: 0.5;
422
+ background: color-mix(in srgb, var(--ev-color) 5%, var(--dt-surface, #f9fafb));
386
423
  }
387
424
  .mw-ev--cancelled .mw-ev-title {
388
425
  text-decoration: line-through;
426
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
427
+ }
428
+ .mw-ev--cancelled .mw-ev-stripe {
429
+ opacity: 0.45; /* decorative bar only */
389
430
  }
390
431
  .mw-ev--tentative {
391
- opacity: 0.65;
392
- border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
432
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, #f9fafb));
433
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
393
434
  }
394
435
  .mw-ev--full {
395
- opacity: 0.55;
436
+ background: color-mix(in srgb, var(--ev-color) 6%, var(--dt-surface, #f9fafb));
437
+ border: 1px solid color-mix(in srgb, var(--ev-color) 30%, transparent);
438
+ }
439
+ .mw-ev--full .mw-ev-title {
440
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
396
441
  }
397
442
  .mw-ev--limited {
398
- opacity: 0.65;
399
- border: 1px dashed color-mix(in srgb, var(--ev-color) 35%, transparent);
443
+ background: color-mix(in srgb, var(--ev-color) 8%, var(--dt-surface, #f9fafb));
444
+ border: 1px dashed color-mix(in srgb, var(--ev-color) 45%, transparent);
400
445
  }
401
446
 
402
447
  .mw-ev-stripe {
@@ -417,7 +462,7 @@ function handleDayKeydown(e, dayMs) {
417
462
  }
418
463
 
419
464
  .mw-ev-title {
420
- font: 500 12px/1.2 var(--dt-sans, system-ui, sans-serif);
465
+ font: 500 15px/1.2 var(--dt-sans, system-ui, sans-serif);
421
466
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
422
467
  white-space: nowrap;
423
468
  overflow: hidden;
@@ -427,16 +472,39 @@ function handleDayKeydown(e, dayMs) {
427
472
  }
428
473
 
429
474
  .mw-ev-time {
430
- font: 400 11px/1 var(--dt-mono, ui-monospace, monospace);
431
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
475
+ font: 400 12px/1 var(--dt-mono, ui-monospace, monospace);
476
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
432
477
  white-space: nowrap;
433
478
  flex-shrink: 0;
434
479
  }
435
480
 
436
481
  .mw-ev-more {
437
- font: 400 11px/1 var(--dt-sans, system-ui, sans-serif);
438
- color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
439
- padding: 2px 0;
482
+ font: 500 12px/1 var(--dt-sans, system-ui, sans-serif);
483
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
484
+ padding: 2px 4px;
485
+ min-height: 32px;
486
+ border: none;
487
+ background: transparent;
488
+ cursor: pointer;
489
+ text-align: left;
490
+ align-self: flex-start;
491
+ position: relative;
492
+ pointer-events: auto;
493
+ -webkit-tap-highlight-color: transparent;
494
+ }
495
+ /* Hit-slop: 44px effective touch target */
496
+ .mw-ev-more::before {
497
+ content: '';
498
+ position: absolute;
499
+ left: 0;
500
+ right: 0;
501
+ top: 50%;
502
+ transform: translateY(-50%);
503
+ height: 44px;
504
+ }
505
+ .mw-ev-more:focus-visible {
506
+ outline: none;
507
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
440
508
  }
441
509
 
442
510
  /* ─── Chevron ────────────────────────────────────── */
@@ -450,7 +518,7 @@ function handleDayKeydown(e, dayMs) {
450
518
 
451
519
  /* ─── Focus ──────────────────────────────────────── */
452
520
  .mw-ev:focus-visible {
453
- outline: 2px solid var(--ev-color, var(--dt-accent));
454
- outline-offset: 1px;
521
+ outline: none;
522
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
455
523
  }
456
524
  </style>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared horizontal-swipe tracker for the mobile views.
3
+ *
4
+ * Tracks touch gestures on a container and reports the live horizontal
5
+ * offset (so the view can follow the finger) plus a commit direction on
6
+ * release. Vertical-dominant movement hands the gesture back to native
7
+ * scrolling immediately.
8
+ */
9
+ /** Unified swipe commit threshold (px) across mobile day + week views. */
10
+ export declare const SWIPE_THRESHOLD = 50;
11
+ export interface SwipeCallbacks {
12
+ /** Live horizontal offset (px) while the finger is down. Reset to 0 when the gesture is abandoned. */
13
+ onmove: (dx: number) => void;
14
+ /**
15
+ * Gesture finished.
16
+ * `dir` is `1` (swiped right → previous period), `-1` (swiped left → next period),
17
+ * or `0` (below threshold — snap back).
18
+ */
19
+ onend: (dir: -1 | 0 | 1) => void;
20
+ /** Return true to ignore the gesture entirely (e.g. while a drag-create/resize is active). */
21
+ disabled?: () => boolean;
22
+ }
23
+ export interface SwipeHandlers {
24
+ ontouchstart: (e: TouchEvent) => void;
25
+ ontouchmove: (e: TouchEvent) => void;
26
+ ontouchend: () => void;
27
+ ontouchcancel: () => void;
28
+ }
29
+ export declare function createSwipe(cb: SwipeCallbacks): SwipeHandlers;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared horizontal-swipe tracker for the mobile views.
3
+ *
4
+ * Tracks touch gestures on a container and reports the live horizontal
5
+ * offset (so the view can follow the finger) plus a commit direction on
6
+ * release. Vertical-dominant movement hands the gesture back to native
7
+ * scrolling immediately.
8
+ */
9
+ /** Unified swipe commit threshold (px) across mobile day + week views. */
10
+ export const SWIPE_THRESHOLD = 50;
11
+ export function createSwipe(cb) {
12
+ let startX = 0;
13
+ let startY = 0;
14
+ let tracking = false;
15
+ let dx = 0;
16
+ function abandon() {
17
+ tracking = false;
18
+ if (dx !== 0) {
19
+ dx = 0;
20
+ cb.onmove(0);
21
+ }
22
+ }
23
+ return {
24
+ ontouchstart(e) {
25
+ if (cb.disabled?.()) {
26
+ tracking = false;
27
+ return;
28
+ }
29
+ const t = e.touches[0];
30
+ startX = t.clientX;
31
+ startY = t.clientY;
32
+ tracking = true;
33
+ dx = 0;
34
+ },
35
+ ontouchmove(e) {
36
+ if (!tracking)
37
+ return;
38
+ if (cb.disabled?.()) {
39
+ abandon();
40
+ return;
41
+ }
42
+ const t = e.touches[0];
43
+ const mx = t.clientX - startX;
44
+ const my = t.clientY - startY;
45
+ // Vertical movement dominates → this is a scroll, not a swipe.
46
+ if (Math.abs(my) > Math.abs(mx) * 0.8) {
47
+ abandon();
48
+ return;
49
+ }
50
+ dx = mx;
51
+ cb.onmove(dx);
52
+ },
53
+ ontouchend() {
54
+ if (!tracking) {
55
+ cb.onend(0);
56
+ return;
57
+ }
58
+ tracking = false;
59
+ const dir = Math.abs(dx) > SWIPE_THRESHOLD ? (dx > 0 ? 1 : -1) : 0;
60
+ dx = 0;
61
+ cb.onend(dir);
62
+ },
63
+ ontouchcancel() {
64
+ // The browser took over the gesture (scroll, edge-swipe, system UI).
65
+ // Without this, the view stays stuck at the last swipe offset.
66
+ if (!tracking)
67
+ return;
68
+ tracking = false;
69
+ dx = 0;
70
+ cb.onend(0);
71
+ },
72
+ };
73
+ }