@nomideusz/svelte-calendar 0.20.1 → 0.21.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.
@@ -7,8 +7,10 @@
7
7
  Modelled after Hey Calendar's week view:
8
8
  • Weeks stack vertically. Scroll up = past, down = future.
9
9
  • Day headers in each week row: "MON 23", "TUE 24", with accent pill for today.
10
- • Month label in left gutter, vertical bottom-to-top.
11
- • Events are clean horizontal bars with colour fill, "9AM- 10AM Title" inline.
10
+ • Month label in left gutter, vertical bottom-to-top; a month that begins
11
+ mid-week is labelled, with its year, in the column where it begins.
12
+ • Events are clean horizontal bars with colour fill, "9AM- 10AM Title" inline,
13
+ each at the height of its start time in the day.
12
14
  • Generous whitespace, thin dividers, minimal chrome.
13
15
  -->
14
16
  <script lang="ts">import { onMount, tick, untrack } from "svelte";
@@ -18,7 +20,7 @@ import { prefersReducedMotion } from "svelte/motion";
18
20
  import { useCalendarContext } from "../shared/context.svelte.js";
19
21
  import EventContent from "../shared/EventContent.svelte";
20
22
  import { createClock } from "../../core/clock.svelte.js";
21
- import { DAY_MS, HOUR_MS, sod } from "../../core/time.js";
23
+ import { DAY_MS, HOUR_MS, sod, addDaysMs } from "../../core/time.js";
22
24
  import { startOfWeek as sowFn, isAllDay, isMultiDay, segmentForDay } from "../../core/time.js";
23
25
  import { weekdayShort, monthLong, fmtTime as _fmtTime, getLabels } from "../../core/locale.js";
24
26
  import { createChipFit } from "../shared/chip-fit.svelte.js";
@@ -61,12 +63,58 @@ const MAX_EVENTS_SHOWN = 5;
61
63
  // mount re-centres once the events have landed.
62
64
  const CHIP_H = 22;
63
65
  const CHIP_GAP = 3;
64
- const ROW_MIN = 120;
66
+ /** The day's hours at their floor height — a quiet week is short, a busy
67
+ * day grows its row. Chips sit on this axis by start time. */
68
+ const TIME_H = 120;
65
69
  const ROW_MARGIN = 12;
66
70
  /** Distance between two rows' tops; only a fallback now. */
67
71
  function rowPitch() {
68
72
  const rows = el?.querySelectorAll("[data-week]");
69
- return rows && rows.length > 1 ? rows[1].offsetTop - rows[0].offsetTop : ROW_MIN + ROW_MARGIN;
73
+ return rows && rows.length > 1 ? rows[1].offsetTop - rows[0].offsetTop : TIME_H + ROW_MARGIN;
74
+ }
75
+ // ─── Time axis ──────────────────────────────────
76
+ // Without visibleHours the axis spans the hours the loaded events start in:
77
+ // a timetable of 7:00–21:00 classes spread over 24 hours bunches them up.
78
+ const eventHours = $derived.by(() => {
79
+ let lo = 24;
80
+ let hi = -1;
81
+ for (const ev of events) {
82
+ if (isAllDay(ev) || isMultiDay(ev)) continue;
83
+ const h = ev.start.getHours() + ev.start.getMinutes() / 60;
84
+ lo = Math.min(lo, h);
85
+ hi = Math.max(hi, h);
86
+ }
87
+ return hi < 0 ? [0, 24] : [Math.floor(lo), Math.min(24, Math.floor(hi) + 1)];
88
+ });
89
+ const startHour = $derived(visibleHours?.[0] ?? eventHours[0]);
90
+ const endHour = $derived(visibleHours?.[1] ?? eventHours[1]);
91
+ /** Where a chip starting this far into the day sits, px from the axis top.
92
+ * A chip at the last hour still ends inside the floor height. */
93
+ function timeTop(msIntoDay) {
94
+ const f = (msIntoDay / HOUR_MS - startHour) / Math.max(1, endHour - startHour);
95
+ return Math.min(Math.max(f, 0), 1) * (TIME_H - CHIP_H);
96
+ }
97
+ /** Each chip at its start time, or just under the chip before it when that
98
+ * one is in the way: the order is time, the height is time, nothing overlaps. */
99
+ function chipTops(list, dayMs) {
100
+ const tops = [];
101
+ let min = 0;
102
+ for (const ev of list) {
103
+ const top = Math.max(timeTop(ev.start.getTime() - dayMs), min);
104
+ tops.push(top);
105
+ min = top + CHIP_H + CHIP_GAP;
106
+ }
107
+ return tops;
108
+ }
109
+ /** The now-line: at the clock's height (pinned to the axis ends outside
110
+ * it), but never above a chip that has already started — pushed-down
111
+ * chips would read as still to come. */
112
+ function nowTop(list, tops, dayMs) {
113
+ let y = timeTop(clock.tick - dayMs);
114
+ list.forEach((ev, i) => {
115
+ if (ev.start.getTime() <= clock.tick) y = Math.max(y, tops[i] + CHIP_H + CHIP_GAP / 2);
116
+ });
117
+ return y;
70
118
  }
71
119
  const _initMs = untrack(() => sod(focusDate?.getTime() ?? Date.now()));
72
120
  let internalFocusMs = $state(_initMs);
@@ -80,7 +128,7 @@ function scrollWeekIntoContainer(targetMs, behavior = "auto") {
80
128
  const rows = el.querySelectorAll("[data-week]");
81
129
  for (const row of rows) {
82
130
  const weekMs = Number(row.dataset.week);
83
- if (weekMs <= targetMs && targetMs < weekMs + customDays * DAY_MS) {
131
+ if (weekMs <= targetMs && targetMs < addDaysMs(weekMs, customDays)) {
84
132
  target = row;
85
133
  break;
86
134
  }
@@ -104,8 +152,8 @@ const anchorPeriodStart = $derived(customDays === 7 ? sowFn(internalFocusMs, mon
104
152
  // what range we need. Calendar's single $effect handles loading.
105
153
  $effect(() => {
106
154
  if (!loadRangeCtx) return;
107
- const rangeStart = new Date(anchorPeriodStart - bufferBefore * customDays * DAY_MS);
108
- const rangeEnd = new Date(anchorPeriodStart + (bufferAfter + 1) * customDays * DAY_MS);
155
+ const rangeStart = new Date(addDaysMs(anchorPeriodStart, -bufferBefore * customDays));
156
+ const rangeEnd = new Date(addDaysMs(anchorPeriodStart, (bufferAfter + 1) * customDays));
109
157
  loadRangeCtx.set({
110
158
  start: rangeStart,
111
159
  end: rangeEnd
@@ -115,20 +163,20 @@ $effect(() => {
115
163
  const weeks = $derived.by(() => {
116
164
  const result = [];
117
165
  for (let w = -bufferBefore; w <= bufferAfter; w++) {
118
- const periodStart = anchorPeriodStart + w * customDays * DAY_MS;
119
- const isCurrent = todayMs >= periodStart && todayMs < periodStart + customDays * DAY_MS;
166
+ // Calendar days, not 24 h steps: past a DST change a sum of DAY_MS
167
+ // lands at 23:00 the day before, and every later row shifts a day.
168
+ const periodStart = addDaysMs(anchorPeriodStart, w * customDays);
169
+ const isCurrent = todayMs >= periodStart && todayMs < addDaysMs(periodStart, customDays);
120
170
  const days = [];
121
171
  for (let d = 0; d < customDays; d++) {
122
- const ms = periodStart + d * DAY_MS;
172
+ const ms = addDaysMs(periodStart, d);
123
173
  const date = new Date(ms);
124
174
  const dayNum = date.getDate();
125
175
  const dow = date.getDay();
126
176
  const isWeekend = dow === 0 || dow === 6;
127
177
  const isToday = ms === todayMs;
128
178
  const isPast = equalDays ? false : ms < todayMs;
129
- const isFirstOfMonth = dayNum === 1;
130
- const monthLabel = d === 0 || isFirstOfMonth ? monthLong(ms, locale).toUpperCase() : null;
131
- const dayEnd = ms + DAY_MS;
179
+ const dayEnd = addDaysMs(ms, 1);
132
180
  const dayEventsAll = events.filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > ms).sort((a, b) => a.start.getTime() - b.start.getTime());
133
181
  // Separate all-day / multi-day from timed events
134
182
  const timedEvents = [];
@@ -147,20 +195,16 @@ const weeks = $derived.by(() => {
147
195
  isToday,
148
196
  isPast,
149
197
  isWeekend,
150
- isFirstOfMonth,
151
- monthLabel,
198
+ monthLabel: null,
152
199
  events: timedEvents,
153
200
  allDaySegments
154
201
  });
155
202
  }
156
- // Month label: show when first day of period is day 1-7 (for 7-day), or first day of period (for custom)
157
- const startDate = new Date(periodStart);
158
- const showMonth = customDays === 7 ? startDate.getDate() <= 7 : startDate.getDate() <= customDays;
159
- const monthLabel = showMonth ? monthLong(periodStart, locale).toUpperCase() : null;
160
203
  result.push({
161
204
  weekStart: periodStart,
162
205
  isCurrent,
163
- monthLabel,
206
+ monthLabel: "",
207
+ monthStart: false,
164
208
  days
165
209
  });
166
210
  }
@@ -175,6 +219,24 @@ const weeks = $derived.by(() => {
175
219
  });
176
220
  }
177
221
  }
222
+ // Month labels, from the days actually shown — a hidden 1st hands its
223
+ // label to the next shown day. The gutter names the row's month; a month
224
+ // that begins after the row's first day is named where it begins.
225
+ for (const row of result) {
226
+ let prev = new Date(addDaysMs(row.weekStart, -1)).getMonth();
227
+ row.days.forEach((day, i) => {
228
+ const date = new Date(day.ms);
229
+ const starts = date.getMonth() !== prev;
230
+ prev = date.getMonth();
231
+ const label = monthLong(day.ms, locale).toUpperCase() + (starts ? ` ${date.getFullYear()}` : "");
232
+ if (i === 0) {
233
+ row.monthLabel = label;
234
+ row.monthStart = starts;
235
+ } else if (starts) {
236
+ day.monthLabel = label;
237
+ }
238
+ });
239
+ }
178
240
  return result;
179
241
  });
180
242
  // ─── Format helpers ─────────────────────────────────
@@ -385,7 +447,7 @@ const previewKeySnapshot = new Map();
385
447
  function dragPreviewTimedForDay(dayMs) {
386
448
  const ev = dragPreviewEvent;
387
449
  if (!ev || isAllDay(ev) || isMultiDay(ev)) return null;
388
- const dayEnd = dayMs + DAY_MS;
450
+ const dayEnd = addDaysMs(dayMs, 1);
389
451
  const hit = ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs;
390
452
  if (hit) previewKeySnapshot.set("timed", ev.id);
391
453
  return hit ? ev : null;
@@ -415,7 +477,9 @@ const fit = createChipFit({
415
477
  room: ".wg-probe .wg-ev-loc"
416
478
  }
417
479
  });
418
- function chipParts(ev) {
480
+ /** A cell that carries a month label is this much narrower (.wg-cell--month). */
481
+ const MONTH_INSET = 22;
482
+ function chipParts(ev, inset = 0) {
419
483
  return fit.parts([
420
484
  {
421
485
  key: "time",
@@ -437,7 +501,7 @@ function chipParts(ev) {
437
501
  priority: 1,
438
502
  extra: CHIP_GAP_X
439
503
  }
440
- ], CHIP_PAD_X);
504
+ ], CHIP_PAD_X + inset);
441
505
  }
442
506
  function getCellWidth() {
443
507
  const cell = el?.querySelector(".wg-cell");
@@ -480,8 +544,8 @@ function updateDragFromPointer() {
480
544
  const nowWeekMs = weekMsAtY(evLastY);
481
545
  // A vertical row step spans one period (customDays), not always 7 days
482
546
  const weekOffset = evStartWeekMs && nowWeekMs ? Math.round((nowWeekMs - evStartWeekMs) / (customDays * DAY_MS)) : 0;
483
- const deltaMs = (dayOffset + weekOffset * customDays) * DAY_MS;
484
- drag.updatePointer(new Date(ev.start.getTime() + deltaMs), new Date(ev.end.getTime() + deltaMs));
547
+ const deltaDays = dayOffset + weekOffset * customDays;
548
+ drag.updatePointer(new Date(addDaysMs(ev.start.getTime(), deltaDays)), new Date(addDaysMs(ev.end.getTime(), deltaDays)));
485
549
  }
486
550
  // ─── Auto-scroll under a drag ───────────────────────
487
551
  // Weeks the target is in may be off-screen — without this, moving an event
@@ -577,15 +641,15 @@ function onCellKeydown(e, ms) {
577
641
  return;
578
642
  }
579
643
  let step = 0;
580
- if (e.key === "ArrowRight") step = DAY_MS;
581
- else if (e.key === "ArrowLeft") step = -DAY_MS;
582
- else if (e.key === "ArrowDown") step = customDays * DAY_MS;
583
- else if (e.key === "ArrowUp") step = -customDays * DAY_MS;
644
+ if (e.key === "ArrowRight") step = 1;
645
+ else if (e.key === "ArrowLeft") step = -1;
646
+ else if (e.key === "ArrowDown") step = customDays;
647
+ else if (e.key === "ArrowUp") step = -customDays;
584
648
  if (step === 0) return;
585
649
  e.preventDefault();
586
650
  // Walk in the step direction until a rendered cell is found
587
651
  // (skips hidden days; bails at the buffer edge).
588
- let target = ms + step;
652
+ let target = addDaysMs(ms, step);
589
653
  for (let i = 0; i < 7; i++) {
590
654
  const cell = el?.querySelector(`[data-day="${target}"]`);
591
655
  if (cell) {
@@ -594,7 +658,7 @@ function onCellKeydown(e, ms) {
594
658
  cell.scrollIntoView({ block: "nearest" });
595
659
  return;
596
660
  }
597
- target += step < 0 ? -DAY_MS : DAY_MS;
661
+ target = addDaysMs(target, step < 0 ? -1 : 1);
598
662
  }
599
663
  }
600
664
  </script>
@@ -611,8 +675,8 @@ function onCellKeydown(e, ms) {
611
675
  {/if}
612
676
  {/snippet}
613
677
 
614
- {#snippet timedEventContent(ev: TimelineEvent)}
615
- {@const parts = chipParts(ev)}
678
+ {#snippet timedEventContent(ev: TimelineEvent, inset: number)}
679
+ {@const parts = chipParts(ev, inset)}
616
680
  <EventContent event={ev}>
617
681
  {#if parts.time}<span class="wg-ev-time">{fmtAmPm(ev.start)}</span>{/if}
618
682
  <span class="wg-ev-title">{ev.title}</span>
@@ -624,7 +688,7 @@ function onCellKeydown(e, ms) {
624
688
 
625
689
  <svelte:window onkeydown={onWindowKeydown} />
626
690
 
627
- <div class="wg" class:wg--auto={autoHeight} style={style || undefined} style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')} style:--wg-row-min="{ROW_MIN}px" style:--wg-chip-h="{CHIP_H}px" style:--wg-chip-gap="{CHIP_GAP}px" style:--wg-row-margin="{ROW_MARGIN}px">
691
+ <div class="wg" class:wg--auto={autoHeight} style={style || undefined} style:height={autoHeight ? undefined : (height ? `${height}px` : '100%')} style:--wg-time-h="{TIME_H}px" style:--wg-month-inset="{MONTH_INSET}px" style:--wg-chip-h="{CHIP_H}px" style:--wg-chip-gap="{CHIP_GAP}px" style:--wg-row-margin="{ROW_MARGIN}px">
628
692
  <div
629
693
  class="wg-body"
630
694
  bind:this={el}
@@ -641,6 +705,8 @@ function onCellKeydown(e, ms) {
641
705
  </div>
642
706
  {#each weeks as week (week.weekStart)}
643
707
  <div class="wg-week" class:wg-week--current={week.isCurrent} data-week={week.weekStart} role="presentation">
708
+ <!-- Each cell's aria-label already names its date -->
709
+ <div class="wg-month" class:wg-month--start={week.monthStart} aria-hidden="true">{week.monthLabel}</div>
644
710
  <div class="wg-week-body" role="presentation">
645
711
  <!-- Day columns (header inside each cell) -->
646
712
  <div class="wg-days" role="row">
@@ -652,8 +718,13 @@ function onCellKeydown(e, ms) {
652
718
  {@const hiddenCount = Math.max(0, visibleTimedEvents.length - timedCap)}
653
719
  {@const previewTimedEvent = dragPreviewTimedForDay(day.ms)}
654
720
  {@const previewSegment = dragPreviewSegmentForDay(day.ms)}
721
+ {@const shownTimed = visibleTimedEvents.slice(0, isExpanded ? visibleTimedEvents.length : timedCap)}
722
+ {@const tops = chipTops(shownTimed, day.ms)}
723
+ {@const nowY = day.isToday ? nowTop(shownTimed, tops, day.ms) : null}
724
+ {@const inset = day.monthLabel ? MONTH_INSET : 0}
655
725
  <div
656
726
  class="wg-cell"
727
+ class:wg-cell--month={!!day.monthLabel}
657
728
  class:wg-cell--today={day.isToday}
658
729
  class:wg-cell--past={day.isPast}
659
730
  class:wg-cell--weekend={day.isWeekend}
@@ -671,14 +742,15 @@ function onCellKeydown(e, ms) {
671
742
  onfocus={() => { focusedCellMs = day.ms; }}
672
743
  onkeydown={(e) => onCellKeydown(e, day.ms)}
673
744
  >
674
- <!-- Day label in top-right corner -->
745
+ {#if day.monthLabel}
746
+ <div class="wg-month wg-month--start wg-month--inset" aria-hidden="true">{day.monthLabel}</div>
747
+ {/if}
748
+ <!-- Day label in top-right corner: "MON 23", a pill today -->
675
749
  <div class="wg-cell-hd" class:wg-cell-hd--today={day.isToday}>
750
+ <span class="wg-day-wd">{weekdayShort(day.ms, locale)}</span>
676
751
  {#if showDates}
677
- <span class="wg-day-num" class:wg-day-num--today={day.isToday}>
678
- {day.dayNum}
679
- </span>
752
+ <span class="wg-day-num">{day.dayNum}</span>
680
753
  {/if}
681
- <span class="wg-day-wd">{weekdayShort(day.ms, locale)}</span>
682
754
  </div>
683
755
 
684
756
  <!-- Custom day header snippet -->
@@ -688,27 +760,89 @@ function onCellKeydown(e, ms) {
688
760
  </div>
689
761
  {/if}
690
762
 
691
- <!-- Blocked slots indicator -->
692
- {#if blockedSlots?.length}
693
- {@const jsDay = new Date(day.ms).getDay()}
694
- {@const isoDay = jsDay === 0 ? 7 : jsDay}
695
- {#each blockedSlots as slot, i (i)}
696
- {#if !slot.day || slot.day === isoDay}
697
- {@const slotRange = `${_fmtTime(new Date(day.ms + slot.start * HOUR_MS), locale)} – ${_fmtTime(new Date(day.ms + slot.end * HOUR_MS), locale)}`}
698
- <div
699
- class="wg-blocked"
700
- title="{slot.label ? `${slot.label}, ` : ''}{slotRange}"
701
- aria-label="{slot.label || 'Unavailable'}, {slotRange}"
702
- >
703
- {#if slot.label}
704
- <span class="wg-blocked-label">{slot.label}</span>
705
- {/if}
706
- </div>
707
- {/if}
763
+ <!-- Timed events, on the day's time axis -->
764
+ <div class="wg-cell-events">
765
+ {#if blockedSlots?.length}
766
+ {@const jsDay = new Date(day.ms).getDay()}
767
+ {@const isoDay = jsDay === 0 ? 7 : jsDay}
768
+ {#each blockedSlots as slot, i (i)}
769
+ {#if (!slot.day || slot.day === isoDay) && slot.end > startHour && slot.start < endHour}
770
+ {@const slotRange = `${_fmtTime(new Date(day.ms + slot.start * HOUR_MS), locale)} – ${_fmtTime(new Date(day.ms + slot.end * HOUR_MS), locale)}`}
771
+ {@const bandTop = timeTop(slot.start * HOUR_MS)}
772
+ {@const bandH = Math.max(14, timeTop(slot.end * HOUR_MS) - bandTop)}
773
+ <!-- A chip over the band would cut its label in half: the title keeps it -->
774
+ {@const covered = tops.some((t) => t < bandTop + bandH && t + CHIP_H > bandTop)}
775
+ <div
776
+ class="wg-blocked"
777
+ style:top="{bandTop}px"
778
+ style:height="{bandH}px"
779
+ title="{slot.label ? `${slot.label}, ` : ''}{slotRange}"
780
+ aria-label="{slot.label || 'Unavailable'}, {slotRange}"
781
+ >
782
+ {#if slot.label && !covered}
783
+ <span class="wg-blocked-label">{slot.label}</span>
784
+ {/if}
785
+ </div>
786
+ {/if}
787
+ {/each}
788
+ {/if}
789
+ {#each shownTimed as ev, i (ev.id)}
790
+ <!-- send/receive keyed by event id pair the card with the drag ghost:
791
+ drag start morphs card → ghost, drop morphs ghost → placed card -->
792
+ <div
793
+ animate:flip={{ duration: ANIM }}
794
+ in:previewReceive={{ key: ev.id }}
795
+ out:previewSend={{ key: ev.id }}
796
+ class="wg-ev"
797
+ class:wg-ev--selected={selectedEventId === ev.id}
798
+ class:wg-ev--current={ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
799
+ class:wg-ev--dragging={evDragging && evDragId === ev.id}
800
+ class:wg-ev--readonly={ev.data?.readOnly}
801
+ class:wg-ev--cancelled={ev.status === 'cancelled'}
802
+ class:wg-ev--tentative={ev.status === 'tentative'}
803
+ class:wg-ev--full={ev.status === 'full'}
804
+ class:wg-ev--limited={ev.status === 'limited'}
805
+ style:--ev-color={ev.color ?? 'var(--dt-accent)'}
806
+ style:margin-top="{tops[i] - (i ? tops[i - 1] + CHIP_H : 0)}px"
807
+ role="button"
808
+ tabindex="0"
809
+ aria-label="{ev.title}, {fmtAmPm(ev.start)} – {fmtAmPm(ev.end)}{ev.status === 'cancelled' ? ` (cancelled)` : ''}{ev.status === 'tentative' ? ` (tentative)` : ''}{ev.status === 'full' ? ` (full)` : ''}{ev.status === 'limited' ? ` (limited)` : ''}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
810
+ onpointerdown={(e) => onEventPointerDown(e, ev)}
811
+ onpointerenter={() => oneventhover?.(ev)}
812
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev, e.currentTarget.getBoundingClientRect()); } }}
813
+ >
814
+ {@render timedEventContent(ev, inset)}
815
+ </div>
708
816
  {/each}
709
- {/if}
817
+ {#if hiddenCount > 0}
818
+ <button
819
+ type="button"
820
+ class="wg-ev-more"
821
+ aria-expanded={isExpanded}
822
+ onclick={(e) => { e.stopPropagation(); expandedCells[day.ms] = !isExpanded; }}
823
+ >{isExpanded ? L.showLess : L.nMore(hiddenCount)}</button>
824
+ {/if}
825
+ {#if previewTimedEvent}
826
+ <!-- The ghost floats at its own time; it pushes nothing aside -->
827
+ <div
828
+ class="wg-ev wg-ev--drag-preview"
829
+ style:--ev-color={previewTimedEvent.color ?? 'var(--dt-accent)'}
830
+ style:top="{timeTop(previewTimedEvent.start.getTime() - day.ms)}px"
831
+ aria-hidden="true"
832
+ in:previewReceive={{ key: previewKeySnapshot.get('timed') ?? '' }}
833
+ out:previewSend={{ key: previewKeySnapshot.get('timed') ?? '' }}
834
+ >
835
+ {@render timedEventContent(previewTimedEvent, inset)}
836
+ </div>
837
+ {/if}
838
+ {#if nowY !== null}
839
+ <div class="wg-now" style:top="{nowY}px" aria-hidden="true">
840
+ <span class="wg-now-time">{fmtAmPm(new Date(clock.tick))}</span>
841
+ </div>
842
+ {/if}
843
+ </div>
710
844
 
711
- <!-- All-day / multi-day events -->
845
+ <!-- All-day / multi-day events, under the day (as Hey) -->
712
846
  {#if visibleAllDaySegments.length > 0 || previewSegment}
713
847
  <div class="wg-allday">
714
848
  {#each visibleAllDaySegments as seg (seg.ev.id)}
@@ -749,56 +883,6 @@ function onCellKeydown(e, ms) {
749
883
  {/if}
750
884
  </div>
751
885
  {/if}
752
-
753
- <!-- Timed events -->
754
- <div class="wg-cell-events">
755
- {#each visibleTimedEvents.slice(0, isExpanded ? visibleTimedEvents.length : timedCap) as ev (ev.id)}
756
- <!-- send/receive keyed by event id pair the card with the drag ghost:
757
- drag start morphs card → ghost, drop morphs ghost → placed card -->
758
- <div
759
- animate:flip={{ duration: ANIM }}
760
- in:previewReceive={{ key: ev.id }}
761
- out:previewSend={{ key: ev.id }}
762
- class="wg-ev"
763
- class:wg-ev--selected={selectedEventId === ev.id}
764
- class:wg-ev--current={ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick}
765
- class:wg-ev--dragging={evDragging && evDragId === ev.id}
766
- class:wg-ev--readonly={ev.data?.readOnly}
767
- class:wg-ev--cancelled={ev.status === 'cancelled'}
768
- class:wg-ev--tentative={ev.status === 'tentative'}
769
- class:wg-ev--full={ev.status === 'full'}
770
- class:wg-ev--limited={ev.status === 'limited'}
771
- style:--ev-color={ev.color ?? 'var(--dt-accent)'}
772
- role="button"
773
- tabindex="0"
774
- aria-label="{ev.title}, {fmtAmPm(ev.start)} – {fmtAmPm(ev.end)}{ev.status === 'cancelled' ? ` (cancelled)` : ''}{ev.status === 'tentative' ? ` (tentative)` : ''}{ev.status === 'full' ? ` (full)` : ''}{ev.status === 'limited' ? ` (limited)` : ''}{ev.start.getTime() <= clock.tick && ev.end.getTime() > clock.tick ? ` (${L.inProgress})` : ''}"
775
- onpointerdown={(e) => onEventPointerDown(e, ev)}
776
- onpointerenter={() => oneventhover?.(ev)}
777
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); oneventclick?.(ev, e.currentTarget.getBoundingClientRect()); } }}
778
- >
779
- {@render timedEventContent(ev)}
780
- </div>
781
- {/each}
782
- {#if previewTimedEvent}
783
- <div
784
- class="wg-ev wg-ev--drag-preview"
785
- style:--ev-color={previewTimedEvent.color ?? 'var(--dt-accent)'}
786
- aria-hidden="true"
787
- in:previewReceive={{ key: previewKeySnapshot.get('timed') ?? '' }}
788
- out:previewSend={{ key: previewKeySnapshot.get('timed') ?? '' }}
789
- >
790
- {@render timedEventContent(previewTimedEvent)}
791
- </div>
792
- {/if}
793
- </div>
794
- {#if hiddenCount > 0}
795
- <button
796
- type="button"
797
- class="wg-ev-more"
798
- aria-expanded={isExpanded}
799
- onclick={(e) => { e.stopPropagation(); expandedCells[day.ms] = !isExpanded; }}
800
- >{isExpanded ? L.showLess : L.nMore(hiddenCount)}</button>
801
- {/if}
802
886
  </div>
803
887
  {/each}
804
888
  </div>
@@ -887,7 +971,6 @@ function onCellKeydown(e, ms) {
887
971
  display: flex;
888
972
  flex-direction: column;
889
973
  min-width: 90px;
890
- min-height: var(--wg-row-min, 170px);
891
974
  box-sizing: border-box;
892
975
  padding: 4px 4px 8px;
893
976
  border-right: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
@@ -896,6 +979,34 @@ function onCellKeydown(e, ms) {
896
979
  }
897
980
 
898
981
  .wg-cell:last-child { border-right: none; }
982
+ /* The inset is on the contents, not the cell's padding: flex adds padding
983
+ to a basis-0 cell, and that one column would come out wider. */
984
+ .wg-cell--month .wg-cell-events,
985
+ .wg-cell--month .wg-allday { margin-left: var(--wg-month-inset, 22px); }
986
+
987
+ /* ─── Month labels (vertical, read bottom-to-top) ─── */
988
+ .wg-month {
989
+ flex: 0 0 22px;
990
+ writing-mode: vertical-rl;
991
+ transform: rotate(180deg);
992
+ text-align: end; /* the rotation puts the end at the top */
993
+ padding: 6px 0;
994
+ font: 700 11px / 22px var(--dt-sans, system-ui, sans-serif);
995
+ letter-spacing: 0.08em;
996
+ white-space: nowrap;
997
+ overflow: hidden;
998
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
999
+ }
1000
+ .wg-week--current .wg-month,
1001
+ .wg-month--start {
1002
+ color: var(--dt-text, rgba(0, 0, 0, 0.87));
1003
+ }
1004
+ .wg-month--inset {
1005
+ position: absolute;
1006
+ top: 0;
1007
+ left: 2px;
1008
+ pointer-events: none;
1009
+ }
899
1010
  .wg-cell:hover { background: var(--dt-hover, rgba(0, 0, 0, 0.015)); }
900
1011
 
901
1012
  .wg-cell--today { background: var(--dt-today-bg, rgba(37, 99, 235, 0.04)); }
@@ -933,9 +1044,15 @@ function onCellKeydown(e, ms) {
933
1044
  }
934
1045
 
935
1046
  /* ─── Blocked slot indicator ─────────────────────── */
1047
+ /* A band on the time axis, behind the chips */
936
1048
  .wg-blocked {
1049
+ position: absolute;
1050
+ left: 0;
1051
+ right: 0;
1052
+ box-sizing: border-box;
1053
+ overflow: hidden;
937
1054
  display: flex;
938
- align-items: center;
1055
+ align-items: flex-start;
939
1056
  gap: 3px;
940
1057
  padding: 2px 4px;
941
1058
  border-radius: 3px;
@@ -946,8 +1063,6 @@ function onCellKeydown(e, ms) {
946
1063
  transparent 3px,
947
1064
  transparent 6px
948
1065
  );
949
- margin-bottom: 2px;
950
- min-height: 14px;
951
1066
  }
952
1067
 
953
1068
  .wg-blocked-label {
@@ -965,12 +1080,13 @@ function onCellKeydown(e, ms) {
965
1080
 
966
1081
  /* ─── Cell header (day label top-right) ──────────── */
967
1082
  .wg-cell-hd {
1083
+ align-self: flex-end;
968
1084
  display: flex;
969
- align-items: center;
970
- justify-content: flex-end;
1085
+ align-items: baseline;
971
1086
  gap: 4px;
972
- padding: 4px 5px 2px 0;
973
- margin-bottom: 2px;
1087
+ padding: 3px 7px;
1088
+ margin: 1px 0 2px;
1089
+ border-radius: 999px;
974
1090
  }
975
1091
 
976
1092
  .wg-day-wd {
@@ -984,11 +1100,6 @@ function onCellKeydown(e, ms) {
984
1100
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
985
1101
  }
986
1102
 
987
- .wg-cell-hd--today .wg-day-wd {
988
- color: var(--dt-accent, #2563eb);
989
- font-weight: 600;
990
- }
991
-
992
1103
  .wg-day-num {
993
1104
  font: 700 14px / 1 var(--dt-sans, system-ui, sans-serif);
994
1105
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
@@ -998,9 +1109,13 @@ function onCellKeydown(e, ms) {
998
1109
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
999
1110
  }
1000
1111
 
1001
- .wg-day-num--today {
1002
- color: var(--dt-accent, #2563eb);
1003
- font-weight: 900;
1112
+ /* Today's pill — after the week rules above, which it must beat */
1113
+ .wg-cell-hd--today {
1114
+ background: var(--dt-accent, #2563eb);
1115
+ }
1116
+ .wg-cell-hd--today .wg-day-wd,
1117
+ .wg-cell-hd--today .wg-day-num {
1118
+ color: var(--dt-btn-text, #fff);
1004
1119
  }
1005
1120
 
1006
1121
  /* ─── All-day / multi-day events ─────────────────── */
@@ -1008,7 +1123,7 @@ function onCellKeydown(e, ms) {
1008
1123
  display: flex;
1009
1124
  flex-direction: column;
1010
1125
  gap: var(--wg-chip-gap, 3px);
1011
- margin-bottom: var(--wg-chip-gap, 3px);
1126
+ margin-top: var(--wg-chip-gap, 3px);
1012
1127
  flex-shrink: 0;
1013
1128
  }
1014
1129
 
@@ -1085,14 +1200,39 @@ function onCellKeydown(e, ms) {
1085
1200
  }
1086
1201
 
1087
1202
  /* ─── Events ─────────────────────────────────────── */
1203
+ /* The day's time axis: chips carry their own margin-top (chipTops), and
1204
+ the area fills the cell, so the all-day bars line up at the bottom. */
1088
1205
  .wg-cell-events {
1089
1206
  position: relative;
1090
1207
  display: flex;
1091
1208
  flex-direction: column;
1092
- gap: var(--wg-chip-gap, 3px);
1209
+ flex: 1 0 auto;
1210
+ min-height: var(--wg-time-h, 100px);
1211
+ }
1212
+
1213
+ .wg-now {
1214
+ position: absolute;
1215
+ left: 0;
1216
+ right: 0;
1217
+ z-index: 2;
1218
+ display: flex;
1219
+ align-items: center;
1220
+ gap: 3px;
1221
+ transform: translateY(-50%);
1222
+ pointer-events: none;
1223
+ }
1224
+ .wg-now::after {
1225
+ content: '';
1226
+ flex: 1;
1227
+ border-top: 1px dashed var(--dt-accent, #2563eb);
1228
+ }
1229
+ .wg-now-time {
1230
+ font: 500 9px / 1 var(--dt-sans, system-ui, sans-serif);
1231
+ color: var(--dt-accent, #2563eb);
1093
1232
  }
1094
1233
 
1095
1234
  .wg-ev {
1235
+ position: relative; /* above the blocked bands */
1096
1236
  display: flex;
1097
1237
  align-items: center;
1098
1238
  flex-wrap: nowrap;
@@ -1113,7 +1253,9 @@ function onCellKeydown(e, ms) {
1113
1253
  }
1114
1254
 
1115
1255
  .wg-ev--drag-preview {
1116
- position: relative;
1256
+ position: absolute;
1257
+ left: 0;
1258
+ right: 0;
1117
1259
  z-index: 8;
1118
1260
  opacity: 0.95;
1119
1261
  pointer-events: none;
@@ -131,7 +131,7 @@ const layoutByDay = $derived.by(() => {
131
131
  const rsP = drag?.active && (drag.mode === "resize-start" || drag.mode === "resize-end") ? drag.payload : null;
132
132
  const map = new Map();
133
133
  for (const day of dayCols) {
134
- const dayEnd = day.ms + DAY_MS;
134
+ const dayEnd = addDaysMs(day.ms, 1);
135
135
  const bandStart = day.ms + startHour * HOUR_MS;
136
136
  const bandEnd = day.ms + endHour * HOUR_MS;
137
137
  const infos = [];
@@ -393,7 +393,7 @@ function ghostForDay(dayMs) {
393
393
  const axisGhost = $derived.by(() => {
394
394
  if (!drag?.active || !drag.payload) return null;
395
395
  const s = drag.payload.start.getTime();
396
- const day = dayCols.find((d) => s >= d.ms && s < d.ms + 24 * HOUR_MS);
396
+ const day = dayCols.find((d) => s >= d.ms && s < addDaysMs(d.ms, 1));
397
397
  return day ? ghostForDay(day.ms) : null;
398
398
  });
399
399
  // ── Drag-to-create (mouse: sweep; touch: long-press) ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-calendar",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "A themeable Svelte 5 calendar with Day and Week views — Planner and Agenda.",
5
5
  "type": "module",
6
6
  "sideEffects": [