@nomideusz/svelte-calendar 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,14 @@
1
- <script lang="ts">import { createClock } from "../../core/clock.svelte.js";
1
+ <script lang="ts">/**
2
+ * AgendaWeek — rolling N-day agenda view.
3
+ *
4
+ * "The Week Ahead":
5
+ * Today + tomorrow expanded with time slots/countdowns.
6
+ * Future days compact (dot + time + title).
7
+ * Past days dimmed.
8
+ *
9
+ * Answers: "What's coming up and when do I need to be ready?"
10
+ */
11
+ import { createClock } from "../../core/clock.svelte.js";
2
12
  import { sod, DAY_MS, startOfWeek, dayNum, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
3
13
  import { weekdayLong, monthLong } from "../../core/locale.js";
4
14
  import { useCalendarContext } from "../shared/context.svelte.js";
@@ -6,16 +16,7 @@ import EventContent from "../shared/EventContent.svelte";
6
16
  import { fmtTime, duration, timeUntilMs, progress, groupIntoSlots } from "../shared/format.js";
7
17
  const ctx = useCalendarContext();
8
18
  const L = $derived(ctx.labels);
9
- let {
10
- mondayStart = true,
11
- locale,
12
- height,
13
- events = [],
14
- style = "",
15
- focusDate,
16
- oneventclick,
17
- selectedEventId = null
18
- } = $props();
19
+ let { mondayStart = true, locale, height, events = [], style = "", focusDate, oneventclick, selectedEventId = null } = $props();
19
20
  const clock = createClock(ctx.timezone);
20
21
  const viewState = $derived(ctx.viewState);
21
22
  const equalDays = $derived(ctx.equalDays);
@@ -24,110 +25,153 @@ const hideDays = $derived(ctx.hideDays);
24
25
  const isMobile = $derived(ctx.isMobile);
25
26
  const autoHeight = $derived(ctx.autoHeight);
26
27
  const compact = $derived(ctx.compact);
28
+ // Timetable layout is a desktop affordance — stacked list stays on mobile.
27
29
  const cols = $derived(ctx.columns && !isMobile);
28
30
  const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
29
31
  const oneventhover = $derived(ctx.oneventhover);
32
+ const ondayclick = $derived(ctx.ondayclick);
30
33
  const disabledSet = $derived(ctx.disabledSet);
34
+ function clickDay(ms) {
35
+ ondayclick?.(new Date(ms));
36
+ }
37
+ // ── Day-to-day card drag (timetable columns) ────────
38
+ // This view has no hour axis, so a drag can only choose a DAY: the card
39
+ // keeps its time of day. Wired through the same oneventmove the planner
40
+ // uses (readOnly-gated and timezone-unzoned at the Calendar layer);
41
+ // per-event data.readOnly opts out — same convention as PlannerWeek.
42
+ const oneventmove = $derived(ctx.oneventmove);
43
+ let dragId = $state(null);
44
+ let dropDay = $state(null);
45
+ const canDrag = (ev) => cols && !!oneventmove && !ev.data?.readOnly && !isAllDay(ev) && !isMultiDay(ev);
46
+ /** Valid drop day for the card in flight: not past, not closed. */
47
+ const isDropDay = (day) => dragId !== null && day.tier !== "past" && !disabledSet.has(day.ms);
48
+ function onCardDragStart(e, ev) {
49
+ dragId = String(ev.id);
50
+ if (e.dataTransfer) {
51
+ e.dataTransfer.effectAllowed = "move";
52
+ e.dataTransfer.setData("text/plain", String(ev.id));
53
+ }
54
+ }
55
+ function onDayDrop(e, day) {
56
+ if (!isDropDay(day)) return;
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ const ev = events.find((x) => String(x.id) === dragId);
60
+ dragId = null;
61
+ dropDay = null;
62
+ if (!ev) return;
63
+ // Same-day drop is a no-op, not a zero-minute "move" for the host to reject.
64
+ if (sod(ev.start.getTime()) === day.ms) return;
65
+ const start = new Date(day.ms + (ev.start.getTime() - sod(ev.start.getTime())));
66
+ const end = new Date(start.getTime() + (ev.end.getTime() - ev.start.getTime()));
67
+ oneventmove?.(ev, start, end);
68
+ }
69
+ // ── Swipe navigation (mobile, touch only) ──────────
31
70
  let swipeStartX = 0;
32
71
  let swipeStartY = 0;
33
72
  let swipeActive = false;
34
73
  const SWIPE_THRESHOLD = 50;
35
74
  function onPointerDown(e) {
36
- if (!isMobile || e.pointerType !== "touch") return;
37
- swipeActive = true;
38
- swipeStartX = e.clientX;
39
- swipeStartY = e.clientY;
75
+ if (!isMobile || e.pointerType !== "touch") return;
76
+ swipeActive = true;
77
+ swipeStartX = e.clientX;
78
+ swipeStartY = e.clientY;
40
79
  }
41
80
  function onPointerUp(e) {
42
- if (!swipeActive || e.pointerType !== "touch") return;
43
- swipeActive = false;
44
- const dx = e.clientX - swipeStartX;
45
- const dy = e.clientY - swipeStartY;
46
- if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.4) {
47
- if (dx > 0) viewState?.prev();
48
- else viewState?.next();
49
- }
81
+ if (!swipeActive || e.pointerType !== "touch") return;
82
+ swipeActive = false;
83
+ const dx = e.clientX - swipeStartX;
84
+ const dy = e.clientY - swipeStartY;
85
+ if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.4) {
86
+ if (dx > 0) viewState?.prev();
87
+ else viewState?.next();
88
+ }
50
89
  }
51
90
  function onPointerCancel() {
52
- swipeActive = false;
91
+ swipeActive = false;
53
92
  }
93
+ // ── Compact-day disclosure ("+N more" → expand) ─────
54
94
  let expandedDays = $state([]);
55
95
  function toggleDayExpand(ms) {
56
- expandedDays = expandedDays.includes(ms) ? expandedDays.filter((m) => m !== ms) : [...expandedDays, ms];
96
+ expandedDays = expandedDays.includes(ms) ? expandedDays.filter((m) => m !== ms) : [...expandedDays, ms];
57
97
  }
98
+ // ── Completed-events disclosure ("✓ N completed" → expand) ─────
58
99
  let expandedPast = $state([]);
59
100
  function togglePastExpand(ms) {
60
- expandedPast = expandedPast.includes(ms) ? expandedPast.filter((m) => m !== ms) : [...expandedPast, ms];
101
+ expandedPast = expandedPast.includes(ms) ? expandedPast.filter((m) => m !== ms) : [...expandedPast, ms];
61
102
  }
103
+ // ── Format helpers (delegated to shared/format.ts) ──
104
+ // fmtTime, duration, groupIntoSlots imported at top
105
+ // Thin wrappers that bind locale / clock.tick:
62
106
  const fmt = (d) => fmtTime(d, locale);
63
107
  const eta = (ms) => timeUntilMs(ms, clock.tick, L);
64
108
  const prog = (ev) => progress(ev, clock.tick);
109
+ // ── Event handlers ──────────────────────────────────
65
110
  function handleClick(ev) {
66
- oneventclick?.(ev);
111
+ oneventclick?.(ev);
67
112
  }
68
- const weekStartMs = $derived(
69
- focusDate ? viewState?.dayCount === 7 ? startOfWeek(sod(focusDate.getTime()), mondayStart) : sod(focusDate.getTime()) : viewState?.dayCount === 7 ? startOfWeek(clock.today, mondayStart) : clock.today
70
- );
113
+ const weekStartMs = $derived(focusDate ? viewState?.dayCount === 7 ? startOfWeek(sod(focusDate.getTime()), mondayStart) : sod(focusDate.getTime()) : viewState?.dayCount === 7 ? startOfWeek(clock.today, mondayStart) : clock.today);
71
114
  const customDays = $derived(viewState?.dayCount ?? 7);
72
115
  const weekDays = $derived.by(() => {
73
- const now = clock.tick;
74
- const todayMs = clock.today;
75
- const tomorrowMs = todayMs + DAY_MS;
76
- const out = [];
77
- for (let i = 0; i < customDays; i++) {
78
- const ms = weekStartMs + i * DAY_MS;
79
- const dEnd = ms + DAY_MS;
80
- const dayEvts = events.filter((ev) => ev.start.getTime() < dEnd && ev.end.getTime() > ms).sort((a, b) => a.start.getTime() - b.start.getTime());
81
- const allDayEvts = dayEvts.filter((ev) => isAllDay(ev) || isMultiDay(ev));
82
- const timedEvts = dayEvts.filter((ev) => !isAllDay(ev) && !isMultiDay(ev));
83
- const totalMinutes = timedEvts.reduce((sum, ev) => {
84
- const s = Math.max(ev.start.getTime(), ms);
85
- const e = Math.min(ev.end.getTime(), dEnd);
86
- return sum + (e - s) / 6e4;
87
- }, 0);
88
- const pastEvents = [];
89
- const currentEvents = [];
90
- const upcomingEvents = [];
91
- for (const ev of timedEvts) {
92
- if (ev.end.getTime() <= now) pastEvents.push(ev);
93
- else if (ev.start.getTime() <= now && ev.end.getTime() > now) currentEvents.push(ev);
94
- else upcomingEvents.push(ev);
95
- }
96
- let tier;
97
- if (equalDays) {
98
- tier = "upcoming";
99
- } else if (ms === todayMs) {
100
- tier = "today";
101
- } else if (ms === tomorrowMs) {
102
- tier = "tomorrow";
103
- } else if (ms < todayMs) {
104
- tier = "past";
105
- } else {
106
- tier = "upcoming";
107
- }
108
- out.push({
109
- ms,
110
- dayName: weekdayLong(ms, locale),
111
- dateLabel: `${monthLong(ms, locale)} ${dayNum(ms)}`,
112
- tier,
113
- isToday: ms === todayMs,
114
- events: dayEvts,
115
- allDayEvents: allDayEvts,
116
- timedEvents: timedEvts,
117
- pastEvents,
118
- currentEvents,
119
- upcomingEvents,
120
- totalHours: Math.round(totalMinutes / 60 * 10) / 10
121
- });
122
- }
123
- if (hideDays?.length) {
124
- return out.filter((d) => {
125
- const jsDay = new Date(d.ms).getDay();
126
- const iso = jsDay === 0 ? 7 : jsDay;
127
- return !hideDays.includes(iso);
128
- });
129
- }
130
- return out;
116
+ const now = clock.tick;
117
+ const todayMs = clock.today;
118
+ const tomorrowMs = todayMs + DAY_MS;
119
+ const out = [];
120
+ for (let i = 0; i < customDays; i++) {
121
+ const ms = weekStartMs + i * DAY_MS;
122
+ const dEnd = ms + DAY_MS;
123
+ const dayEvts = events.filter((ev) => ev.start.getTime() < dEnd && ev.end.getTime() > ms).sort((a, b) => a.start.getTime() - b.start.getTime());
124
+ const allDayEvts = dayEvts.filter((ev) => isAllDay(ev) || isMultiDay(ev));
125
+ const timedEvts = dayEvts.filter((ev) => !isAllDay(ev) && !isMultiDay(ev));
126
+ const totalMinutes = timedEvts.reduce((sum, ev) => {
127
+ const s = Math.max(ev.start.getTime(), ms);
128
+ const e = Math.min(ev.end.getTime(), dEnd);
129
+ return sum + (e - s) / 6e4;
130
+ }, 0);
131
+ const pastEvents = [];
132
+ const currentEvents = [];
133
+ const upcomingEvents = [];
134
+ for (const ev of timedEvts) {
135
+ if (ev.end.getTime() <= now) pastEvents.push(ev);
136
+ else if (ev.start.getTime() <= now && ev.end.getTime() > now) currentEvents.push(ev);
137
+ else upcomingEvents.push(ev);
138
+ }
139
+ let tier;
140
+ if (equalDays) {
141
+ tier = "upcoming";
142
+ } else if (ms === todayMs) {
143
+ tier = "today";
144
+ } else if (ms === tomorrowMs) {
145
+ tier = "tomorrow";
146
+ } else if (ms < todayMs) {
147
+ tier = "past";
148
+ } else {
149
+ tier = "upcoming";
150
+ }
151
+ out.push({
152
+ ms,
153
+ dayName: weekdayLong(ms, locale),
154
+ dateLabel: `${monthLong(ms, locale)} ${dayNum(ms)}`,
155
+ tier,
156
+ isToday: ms === todayMs,
157
+ events: dayEvts,
158
+ allDayEvents: allDayEvts,
159
+ timedEvents: timedEvts,
160
+ pastEvents,
161
+ currentEvents,
162
+ upcomingEvents,
163
+ totalHours: Math.round(totalMinutes / 60 * 10) / 10
164
+ });
165
+ }
166
+ // Filter hidden days if hideDays is set
167
+ if (hideDays?.length) {
168
+ return out.filter((d) => {
169
+ const jsDay = new Date(d.ms).getDay();
170
+ const iso = jsDay === 0 ? 7 : jsDay;
171
+ return !hideDays.includes(iso);
172
+ });
173
+ }
174
+ return out;
131
175
  });
132
176
  </script>
133
177
 
@@ -143,6 +187,10 @@ const weekDays = $derived.by(() => {
143
187
  class:ag-card--limited={ev.status === 'limited'}
144
188
  style:--ev-color={ev.color || 'var(--dt-accent)'}
145
189
  aria-label="{ev.title}{ev.status === 'cancelled' ? ' (cancelled)' : ''}{ev.status === 'tentative' ? ' (tentative)' : ''}{ev.status === 'full' ? ' (full)' : ''}{ev.status === 'limited' ? ' (limited)' : ''}, {fmt(ev.start)} to {fmt(ev.end)}, {duration(ev)}"
190
+ class:ag-card--drag={dragId === String(ev.id)}
191
+ draggable={canDrag(ev)}
192
+ ondragstart={(e) => onCardDragStart(e, ev)}
193
+ ondragend={() => ((dragId = null), (dropDay = null))}
146
194
  onclick={() => handleClick(ev)}
147
195
  onpointerenter={() => oneventhover?.(ev)}
148
196
  >
@@ -252,8 +300,13 @@ const weekDays = $derived.by(() => {
252
300
  {@const expanded = day.tier === 'today' || day.tier === 'tomorrow'}
253
301
  {#if day.tier === 'past'}
254
302
  <!-- Past day: single collapsed line -->
255
- <div class="ag-wday ag-wday--past" class:ag-wday--disabled={disabledSet.has(day.ms)} role="listitem">
256
- <div class="ag-wday-head">
303
+ <div class="ag-wday ag-wday--past" class:ag-wday--disabled={disabledSet.has(day.ms)} data-day={day.ms} role="listitem">
304
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
305
+ <div
306
+ class="ag-wday-head"
307
+ class:ag-wday-head--click={!!ondayclick}
308
+ onclick={ondayclick ? () => clickDay(day.ms) : undefined}
309
+ >
257
310
  <div class="ag-wday-head-left">
258
311
  <span class="ag-wday-name">{day.dayName}</span>
259
312
  {#if showDates}<span class="ag-wday-date">{day.dateLabel}</span>{/if}
@@ -278,16 +331,34 @@ const weekDays = $derived.by(() => {
278
331
  {/if}
279
332
  </div>
280
333
  {:else}
334
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
281
335
  <div
282
336
  class="ag-wday"
283
337
  class:ag-wday--today={day.tier === 'today'}
284
338
  class:ag-wday--tomorrow={day.tier === 'tomorrow'}
285
339
  class:ag-wday--equal={equalDays}
286
340
  class:ag-wday--disabled={disabledSet.has(day.ms)}
341
+ class:ag-wday--drop={dropDay === day.ms && isDropDay(day)}
342
+ data-day={day.ms}
287
343
  role="listitem"
344
+ ondragover={(e) => {
345
+ if (!isDropDay(day)) return;
346
+ e.preventDefault();
347
+ dropDay = day.ms;
348
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
349
+ }}
350
+ ondragleave={() => {
351
+ if (dropDay === day.ms) dropDay = null;
352
+ }}
353
+ ondrop={(e) => onDayDrop(e, day)}
288
354
  >
289
355
  <!-- Day header -->
290
- <div class="ag-wday-head">
356
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
357
+ <div
358
+ class="ag-wday-head"
359
+ class:ag-wday-head--click={!!ondayclick}
360
+ onclick={ondayclick ? () => clickDay(day.ms) : undefined}
361
+ >
291
362
  <div class="ag-wday-head-left">
292
363
  {#if day.isToday}
293
364
  <span class="ag-wday-badge">{L.today}</span>
@@ -329,7 +400,12 @@ const weekDays = $derived.by(() => {
329
400
  {/if}
330
401
 
331
402
  {#if day.events.length === 0}
332
- <div class="ag-wday-empty">{L.noEvents}</div>
403
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
404
+ <div
405
+ class="ag-wday-empty"
406
+ class:ag-wday-empty--click={!!ondayclick}
407
+ onclick={ondayclick ? () => clickDay(day.ms) : undefined}
408
+ >{L.noEvents}</div>
333
409
  {:else if compact && !cols}
334
410
  <!-- Compact: minimal dot + time + title rows for all days.
335
411
  Ignored in columns mode — single-line rows truncate to
@@ -692,6 +768,10 @@ const weekDays = $derived.by(() => {
692
768
  padding: 2px 0 4px;
693
769
  }
694
770
 
771
+ .ag-wday-head--click,
772
+ .ag-wday-empty--click {
773
+ cursor: pointer;
774
+ }
695
775
  .ag-wday-head {
696
776
  display: flex;
697
777
  justify-content: space-between;
@@ -984,6 +1064,15 @@ const weekDays = $derived.by(() => {
984
1064
  border-radius: 4px;
985
1065
  }
986
1066
 
1067
+ /* ═══ Day-drop drag ═══ */
1068
+ .ag-card--drag {
1069
+ opacity: 0.4;
1070
+ }
1071
+ .ag-wday--drop {
1072
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 6%, transparent);
1073
+ box-shadow: inset 0 0 0 1px var(--dt-accent, #2563eb);
1074
+ }
1075
+
987
1076
  /* ═══ Timetable columns (desktop) ═══ */
988
1077
  .ag--cols .ag-body {
989
1078
  display: grid;