@nomideusz/svelte-calendar 0.10.0 → 0.12.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.
@@ -24,6 +24,7 @@ const hideDays = $derived(ctx.hideDays);
24
24
  const isMobile = $derived(ctx.isMobile);
25
25
  const autoHeight = $derived(ctx.autoHeight);
26
26
  const compact = $derived(ctx.compact);
27
+ const cols = $derived(ctx.columns && !isMobile);
27
28
  const dayHeaderSnippet = $derived(ctx.dayHeaderSnippet);
28
29
  const oneventhover = $derived(ctx.oneventhover);
29
30
  const disabledSet = $derived(ctx.disabledSet);
@@ -54,6 +55,10 @@ let expandedDays = $state([]);
54
55
  function toggleDayExpand(ms) {
55
56
  expandedDays = expandedDays.includes(ms) ? expandedDays.filter((m) => m !== ms) : [...expandedDays, ms];
56
57
  }
58
+ let expandedPast = $state([]);
59
+ function togglePastExpand(ms) {
60
+ expandedPast = expandedPast.includes(ms) ? expandedPast.filter((m) => m !== ms) : [...expandedPast, ms];
61
+ }
57
62
  const fmt = (d) => fmtTime(d, locale);
58
63
  const eta = (ms) => timeUntilMs(ms, clock.tick, L);
59
64
  const prog = (ev) => progress(ev, clock.tick);
@@ -178,18 +183,71 @@ const weekDays = $derived.by(() => {
178
183
  </button>
179
184
  {/snippet}
180
185
 
186
+ <!-- ═══ Shared compact row snippet ═══ -->
187
+ {#snippet compactRow(ev: TimelineEvent, showLoc: boolean, done: boolean)}
188
+ <button
189
+ type="button"
190
+ class="ag-compact"
191
+ class:ag-compact--selected={selectedEventId === ev.id}
192
+ class:ag-compact--done={done}
193
+ class:ag-compact--cancelled={ev.status === 'cancelled'}
194
+ class:ag-compact--tentative={ev.status === 'tentative'}
195
+ class:ag-compact--full={ev.status === 'full'}
196
+ class:ag-compact--limited={ev.status === 'limited'}
197
+ style:--ev-color={ev.color || 'var(--dt-accent)'}
198
+ aria-label="{ev.title}{done ? `, ${L.completed}` : ''}, {fmt(ev.start)}, {duration(ev)}"
199
+ onclick={() => handleClick(ev)}
200
+ onpointerenter={() => oneventhover?.(ev)}
201
+ >
202
+ <EventContent event={ev}>
203
+ <span class="ag-compact-time">{fmt(ev.start)}</span>
204
+ <div class="ag-compact-main">
205
+ <span class="ag-compact-title">{ev.title}</span>
206
+ {#if showLoc && ev.location}
207
+ <span class="ag-compact-loc">{ev.location}</span>
208
+ {/if}
209
+ {#if ev.subtitle}
210
+ <span class="ag-compact-sub">{ev.subtitle}</span>
211
+ {/if}
212
+ {#if ev.tags?.length}
213
+ {#each ev.tags as tag}
214
+ <span class="ag-compact-tag">{tag}</span>
215
+ {/each}
216
+ {/if}
217
+ </div>
218
+ <span class="ag-compact-dur">{duration(ev)}</span>
219
+ </EventContent>
220
+ </button>
221
+ {/snippet}
222
+
223
+ <!-- ═══ "✓ N completed" disclosure toggle ═══ -->
224
+ {#snippet pastToggle(ms: number, count: number, summary: boolean)}
225
+ {@const open = expandedPast.includes(ms)}
226
+ <button
227
+ type="button"
228
+ class="ag-wday-past-line ag-past-toggle"
229
+ class:ag-wday-past-line--summary={summary}
230
+ aria-expanded={open}
231
+ onclick={() => togglePastExpand(ms)}
232
+ >
233
+ ✓ {L.nCompleted(count)}
234
+ <svg class="ag-past-chevron" class:ag-past-chevron--open={open} viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="12" height="12" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
235
+ </button>
236
+ {/snippet}
237
+
181
238
  <!-- svelte-ignore a11y_no_static_element_interactions -->
182
239
  <div
183
240
  class="ag ag--week"
184
241
  class:ag--mobile={isMobile}
185
242
  class:ag--auto={autoHeight}
243
+ class:ag--cols={cols}
186
244
  style={style || undefined}
187
245
  style:height={height ? `${height}px` : undefined}
188
246
  onpointerdown={onPointerDown}
189
247
  onpointerup={onPointerUp}
190
248
  onpointercancel={onPointerCancel}
191
249
  >
192
- <div class="ag-body" role="list" aria-label={L.weekAhead}>
250
+ <div class="ag-body" role="list" aria-label={L.weekAhead} style:--ag-cols={weekDays.length}>
193
251
  {#each weekDays as day (day.ms)}
194
252
  {@const expanded = day.tier === 'today' || day.tier === 'tomorrow'}
195
253
  {#if day.tier === 'past'}
@@ -207,7 +265,14 @@ const weekDays = $derived.by(() => {
207
265
  {/if}
208
266
  </div>
209
267
  {#if day.timedEvents.length > 0}
210
- <div class="ag-wday-past-line ag-wday-past-line--summary">✓ {L.nCompleted(day.timedEvents.length)}</div>
268
+ {@render pastToggle(day.ms, day.timedEvents.length, true)}
269
+ {#if expandedPast.includes(day.ms)}
270
+ <div class="ag-wday-compact">
271
+ {#each day.timedEvents as ev (ev.id)}
272
+ {@render compactRow(ev, false, true)}
273
+ {/each}
274
+ </div>
275
+ {/if}
211
276
  {:else if day.events.length === 0}
212
277
  <div class="ag-wday-past-line ag-wday-past-line--summary">{L.noEvents}</div>
213
278
  {/if}
@@ -265,42 +330,19 @@ const weekDays = $derived.by(() => {
265
330
 
266
331
  {#if day.events.length === 0}
267
332
  <div class="ag-wday-empty">{L.noEvents}</div>
268
- {:else if compact}
269
- <!-- Compact: minimal dot + time + title rows for all days -->
333
+ {:else if compact && !cols}
334
+ <!-- Compact: minimal dot + time + title rows for all days.
335
+ Ignored in columns mode — single-line rows truncate to
336
+ nothing at column width; cards wrap instead. -->
270
337
  <div class="ag-wday-compact">
271
338
  {#each day.timedEvents as ev (ev.id)}
272
- <button
273
- type="button"
274
- class="ag-compact"
275
- class:ag-compact--selected={selectedEventId === ev.id}
276
- class:ag-compact--cancelled={ev.status === 'cancelled'}
277
- class:ag-compact--tentative={ev.status === 'tentative'}
278
- class:ag-compact--full={ev.status === 'full'}
279
- class:ag-compact--limited={ev.status === 'limited'}
280
- style:--ev-color={ev.color || 'var(--dt-accent)'}
281
- aria-label="{ev.title}, {fmt(ev.start)}, {duration(ev)}"
282
- onclick={() => handleClick(ev)}
283
- onpointerenter={() => oneventhover?.(ev)}
284
- >
285
- <EventContent event={ev}>
286
- <span class="ag-compact-dot"></span>
287
- <span class="ag-compact-time">{fmt(ev.start)}</span>
288
- <span class="ag-compact-title">{ev.title}</span>
289
- {#if ev.subtitle}
290
- <span class="ag-compact-sub">{ev.subtitle}</span>
291
- {/if}
292
- {#if ev.tags?.length}
293
- {#each ev.tags as tag}
294
- <span class="ag-compact-tag">{tag}</span>
295
- {/each}
296
- {/if}
297
- <span class="ag-compact-dur">{duration(ev)}</span>
298
- </EventContent>
299
- </button>
339
+ {@render compactRow(ev, false, false)}
300
340
  {/each}
301
341
  </div>
302
- {:else if equalDays}
303
- <!-- Equal days: card layout for all days, no time-relative badges -->
342
+ {:else if equalDays || (cols && !expanded)}
343
+ <!-- Equal days (or timetable columns): card layout, no time-relative
344
+ badges. In columns, today/tomorrow still fall through to the
345
+ expanded branch below for now/ETA treatment. -->
304
346
  <div class="ag-wday-expanded">
305
347
  {#each groupIntoSlots(day.timedEvents) as slot (slot.startMs)}
306
348
  <div class="ag-wslot">
@@ -337,7 +379,12 @@ const weekDays = $derived.by(() => {
337
379
  </div>
338
380
  {/each}
339
381
  {#if day.pastEvents.length > 0}
340
- <div class="ag-wday-past-line">✓ {L.nCompleted(day.pastEvents.length)}</div>
382
+ {@render pastToggle(day.ms, day.pastEvents.length, false)}
383
+ {#if expandedPast.includes(day.ms)}
384
+ {#each day.pastEvents as ev (ev.id)}
385
+ {@render compactRow(ev, false, true)}
386
+ {/each}
387
+ {/if}
341
388
  {/if}
342
389
  </div>
343
390
  {:else}
@@ -345,37 +392,7 @@ const weekDays = $derived.by(() => {
345
392
  {@const dayExpanded = expandedDays.includes(day.ms)}
346
393
  <div class="ag-wday-compact">
347
394
  {#each (dayExpanded ? day.timedEvents : day.timedEvents.slice(0, 4)) as ev (ev.id)}
348
- <button
349
- type="button"
350
- class="ag-compact"
351
- class:ag-compact--selected={selectedEventId === ev.id}
352
- class:ag-compact--cancelled={ev.status === 'cancelled'}
353
- class:ag-compact--tentative={ev.status === 'tentative'}
354
- class:ag-compact--full={ev.status === 'full'}
355
- class:ag-compact--limited={ev.status === 'limited'}
356
- style:--ev-color={ev.color || 'var(--dt-accent)'}
357
- aria-label="{ev.title}, {fmt(ev.start)}, {duration(ev)}"
358
- onclick={() => handleClick(ev)}
359
- onpointerenter={() => oneventhover?.(ev)}
360
- >
361
- <EventContent event={ev}>
362
- <span class="ag-compact-dot"></span>
363
- <span class="ag-compact-time">{fmt(ev.start)}</span>
364
- <span class="ag-compact-title">{ev.title}</span>
365
- {#if ev.location}
366
- <span class="ag-compact-loc">{ev.location}</span>
367
- {/if}
368
- {#if ev.subtitle}
369
- <span class="ag-compact-sub">{ev.subtitle}</span>
370
- {/if}
371
- {#if ev.tags?.length}
372
- {#each ev.tags as tag}
373
- <span class="ag-compact-tag">{tag}</span>
374
- {/each}
375
- {/if}
376
- <span class="ag-compact-dur">{duration(ev)}</span>
377
- </EventContent>
378
- </button>
395
+ {@render compactRow(ev, true, false)}
379
396
  {/each}
380
397
  {#if day.timedEvents.length > 4}
381
398
  <button
@@ -421,7 +438,8 @@ const weekDays = $derived.by(() => {
421
438
  .ag-card,
422
439
  .ag-allday-chip,
423
440
  .ag-compact,
424
- .ag-compact-more {
441
+ .ag-compact-more,
442
+ .ag-past-toggle {
425
443
  font: inherit;
426
444
  color: inherit;
427
445
  text-align: left;
@@ -439,10 +457,15 @@ const weekDays = $derived.by(() => {
439
457
  overflow-y: auto;
440
458
  overflow-x: hidden;
441
459
  box-sizing: border-box;
442
- padding-top: 8px;
460
+ /* No padding-top here: the sticky day headers pin at the scrollport
461
+ edge, and container padding would leave a see-through band above
462
+ them where scrolled cards bleed out. */
443
463
  scrollbar-width: thin;
444
464
  scrollbar-color: var(--dt-border) transparent;
445
465
  }
466
+ .ag-wday:first-child .ag-wday-head {
467
+ padding-top: 12px;
468
+ }
446
469
  .ag--auto .ag-body {
447
470
  overflow-y: visible;
448
471
  }
@@ -678,6 +701,10 @@ const weekDays = $derived.by(() => {
678
701
  top: 0;
679
702
  background: var(--dt-bg, #fff);
680
703
  z-index: 1;
704
+ /* Own compositor layer: without it, fast (async) scrolling repaints
705
+ the pinned header a frame late and a gap flashes above it. */
706
+ transform: translateZ(0);
707
+ will-change: transform;
681
708
  }
682
709
  .ag-wday-head-left {
683
710
  display: flex;
@@ -759,6 +786,43 @@ const weekDays = $derived.by(() => {
759
786
  .ag-wday-past-line--summary {
760
787
  padding: 0 20px 8px;
761
788
  }
789
+ /* "✓ N completed" is a disclosure — tap to reveal the finished events */
790
+ .ag-past-toggle {
791
+ display: inline-flex;
792
+ align-items: center;
793
+ gap: 5px;
794
+ cursor: pointer;
795
+ min-height: 32px;
796
+ transition: color 150ms;
797
+ -webkit-tap-highlight-color: transparent;
798
+ }
799
+ .ag-past-toggle:hover,
800
+ .ag-past-toggle:active {
801
+ color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
802
+ }
803
+ .ag-past-toggle:focus-visible {
804
+ outline: none;
805
+ box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
806
+ border-radius: 4px;
807
+ }
808
+ .ag-past-chevron {
809
+ transition: transform 120ms;
810
+ }
811
+ .ag-past-chevron--open {
812
+ transform: rotate(180deg);
813
+ }
814
+ @media (prefers-reduced-motion: reduce) {
815
+ .ag-past-chevron { transition: none; }
816
+ }
817
+ /* Revealed completed events: dim + strike, single token layer */
818
+ .ag-compact--done .ag-compact-title {
819
+ text-decoration: line-through;
820
+ text-decoration-color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
821
+ }
822
+ .ag-compact--done .ag-compact-time {
823
+ color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
824
+ font-weight: 400;
825
+ }
762
826
 
763
827
  /* Compact day events */
764
828
  .ag-wday-compact {
@@ -776,8 +840,10 @@ const weekDays = $derived.by(() => {
776
840
  .ag-compact--selected {
777
841
  background: color-mix(in srgb, var(--ev-color) 10%, transparent);
778
842
  border-radius: 4px;
779
- padding-left: 6px;
780
- padding-right: 6px;
843
+ /* The highlight bleeds into the gutter via a spread shadow — zero
844
+ layout impact, so nothing shifts or clips even when the host
845
+ reduces the gutters below the bleed width. */
846
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--ev-color) 10%, transparent);
781
847
  }
782
848
  .ag-compact:hover .ag-compact-title,
783
849
  .ag-compact:active .ag-compact-title {
@@ -792,29 +858,52 @@ const weekDays = $derived.by(() => {
792
858
  box-shadow: 0 0 0 2px var(--dt-accent, #2563eb);
793
859
  border-radius: 4px;
794
860
  }
795
- .ag-compact-dot {
796
- width: 5px;
797
- height: 5px;
798
- border-radius: 50%;
799
- background: var(--ev-color, var(--dt-accent));
800
- flex-shrink: 0;
801
- align-self: center;
802
- }
861
+ /* The time label doubles as the class-color signal (replaces the old
862
+ dot): the event color mixed toward the text color, so it stays
863
+ legible on any palette and costs zero horizontal space. */
803
864
  .ag-compact-time {
804
865
  font-size: 11px;
805
866
  font-family: var(--dt-mono, monospace);
806
- color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
867
+ font-weight: 500;
868
+ color: color-mix(in srgb, var(--ev-color, var(--dt-accent)) 60%, var(--dt-text, rgba(0, 0, 0, 0.87)));
807
869
  min-width: 40px;
808
870
  flex-shrink: 0;
809
871
  white-space: nowrap;
810
872
  line-height: 1.4;
811
873
  }
874
+ /* Title + location + subtitle + tags cluster. One line while it fits; on
875
+ mobile the metadata wraps to a second line instead of crushing the title. */
876
+ .ag-compact-main {
877
+ display: flex;
878
+ align-items: baseline;
879
+ gap: 6px;
880
+ flex: 1;
881
+ min-width: 0;
882
+ }
883
+ .ag--mobile .ag-compact-main {
884
+ flex-wrap: wrap;
885
+ row-gap: 2px;
886
+ }
887
+ /* Mobile: size the title by its content when deciding line breaks — a long
888
+ title claims the first line whole (ellipsizing only against the full row)
889
+ and pushes location/subtitle/tags down instead of truncating at 35%. */
890
+ .ag--mobile .ag-compact-title {
891
+ flex-basis: auto;
892
+ }
893
+ /* On their own wrapped line the metadata gets the full width — the tight
894
+ desktop caps would truncate it beside empty space. */
895
+ .ag--mobile .ag-compact-loc,
896
+ .ag--mobile .ag-compact-sub {
897
+ max-width: 100%;
898
+ }
812
899
  .ag-compact-title {
813
900
  font-size: 12px;
814
901
  font-weight: 500;
815
902
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
816
903
  flex: 1;
817
- min-width: 0;
904
+ /* The title is the row's identity — never let subtitle/tags/duration
905
+ squeeze it out on narrow screens (min-width: 0 resolves to 0px). */
906
+ min-width: 35%;
818
907
  white-space: nowrap;
819
908
  overflow: hidden;
820
909
  text-overflow: ellipsis;
@@ -832,7 +921,8 @@ const weekDays = $derived.by(() => {
832
921
  .ag-compact-sub {
833
922
  font-size: 10px;
834
923
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
835
- flex-shrink: 0;
924
+ flex-shrink: 3;
925
+ min-width: 0;
836
926
  white-space: nowrap;
837
927
  overflow: hidden;
838
928
  text-overflow: ellipsis;
@@ -842,7 +932,8 @@ const weekDays = $derived.by(() => {
842
932
  .ag-compact-loc {
843
933
  font-size: 10px;
844
934
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
845
- flex-shrink: 0;
935
+ flex-shrink: 3;
936
+ min-width: 0;
846
937
  white-space: nowrap;
847
938
  overflow: hidden;
848
939
  text-overflow: ellipsis;
@@ -870,7 +961,8 @@ const weekDays = $derived.by(() => {
870
961
  padding: 1px 4px;
871
962
  border-radius: 3px;
872
963
  white-space: nowrap;
873
- flex-shrink: 0;
964
+ flex-shrink: 1;
965
+ min-width: 2.5em;
874
966
  max-width: 80px;
875
967
  overflow: hidden;
876
968
  text-overflow: ellipsis;
@@ -892,6 +984,51 @@ const weekDays = $derived.by(() => {
892
984
  border-radius: 4px;
893
985
  }
894
986
 
987
+ /* ═══ Timetable columns (desktop) ═══ */
988
+ .ag--cols .ag-body {
989
+ display: grid;
990
+ grid-template-columns: repeat(var(--ag-cols, 7), minmax(0, 1fr));
991
+ }
992
+ /* Columns stretch to the tallest day, so the separator runs full height */
993
+ .ag--cols .ag-wday {
994
+ border-bottom: none;
995
+ border-inline-start: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
996
+ min-width: 0;
997
+ }
998
+ .ag--cols .ag-wday:first-child {
999
+ border-inline-start: none;
1000
+ }
1001
+ /* Uniform head padding — the :first-child top bump would misalign columns.
1002
+ Also overrides the past-day head variant (extra class = higher specificity). */
1003
+ .ag--cols .ag-wday .ag-wday-head {
1004
+ padding: 12px 10px 8px;
1005
+ }
1006
+ /* Badge + name + date won't fit one line in a ~160px column */
1007
+ .ag--cols .ag-wday-head-left {
1008
+ flex-wrap: wrap;
1009
+ row-gap: 2px;
1010
+ }
1011
+ .ag--cols .ag-wday-expanded,
1012
+ .ag--cols .ag-wday-compact,
1013
+ .ag--cols .ag-wday-empty,
1014
+ .ag--cols .ag-wday-past-line--summary {
1015
+ padding-left: 10px;
1016
+ padding-right: 10px;
1017
+ }
1018
+ .ag--cols .ag-allday {
1019
+ padding-left: 10px;
1020
+ padding-right: 10px;
1021
+ }
1022
+ /* Narrow cards: long titles get a third line, meta wraps instead of clipping */
1023
+ .ag--cols .ag-card-title {
1024
+ -webkit-line-clamp: 3;
1025
+ line-clamp: 3;
1026
+ }
1027
+ .ag--cols .ag-card-meta {
1028
+ flex-wrap: wrap;
1029
+ row-gap: 3px;
1030
+ }
1031
+
895
1032
  /* ═══ Mobile adaptations ═══ */
896
1033
  .ag--mobile .ag-wday-head {
897
1034
  padding: 12px 16px;
@@ -398,6 +398,7 @@ $effect(() => {
398
398
  ontouchstart={swipe.ontouchstart}
399
399
  ontouchmove={swipe.ontouchmove}
400
400
  ontouchend={swipe.ontouchend}
401
+ ontouchcancel={swipe.ontouchcancel}
401
402
  >
402
403
  <div
403
404
  class="mb-swipe"
@@ -504,17 +505,20 @@ $effect(() => {
504
505
  <div class="mb-ev-stripe"></div>
505
506
  <div class="mb-ev-body">
506
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. -->
507
511
  <span class="mb-ev-title">{p.ev.title}</span>
508
512
  {#if p.height > 32}
509
513
  <span class="mb-ev-time">{fmtTime(p.ev.start, locale)} – {fmtTime(p.ev.end, locale)}</span>
510
514
  {/if}
511
- {#if p.ev.subtitle && p.height > 48}
515
+ {#if p.ev.subtitle && p.height > 56}
512
516
  <span class="mb-ev-sub">{p.ev.subtitle}</span>
513
517
  {/if}
514
- {#if p.ev.location && p.height > 56}
518
+ {#if p.ev.location && p.height > 72}
515
519
  <span class="mb-ev-loc">{p.ev.location}</span>
516
520
  {/if}
517
- {#if p.ev.tags?.length && p.height > 56}
521
+ {#if p.ev.tags?.length && p.height > 88}
518
522
  <div class="mb-ev-tags">
519
523
  {#each p.ev.tags as tag}
520
524
  <span class="mb-ev-tag">{tag}</span>
@@ -751,6 +755,9 @@ $effect(() => {
751
755
 
752
756
  .mb-hour-label {
753
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;
754
761
  flex-shrink: 0;
755
762
  font: 500 11px/1 var(--dt-mono, ui-monospace, monospace);
756
763
  color: var(--dt-text-3, rgba(0, 0, 0, 0.38));
@@ -801,6 +808,7 @@ $effect(() => {
801
808
 
802
809
  .mb-now-label {
803
810
  width: 40px;
811
+ box-sizing: border-box;
804
812
  flex-shrink: 0;
805
813
  text-align: right;
806
814
  padding-right: 6px;
@@ -143,6 +143,7 @@ function handleDayKeydown(e, dayMs) {
143
143
  ontouchstart={swipe.ontouchstart}
144
144
  ontouchmove={swipe.ontouchmove}
145
145
  ontouchend={swipe.ontouchend}
146
+ ontouchcancel={swipe.ontouchcancel}
146
147
  >
147
148
  <!-- Vertical day list -->
148
149
  <div
@@ -251,6 +252,7 @@ function handleDayKeydown(e, dayMs) {
251
252
  .mw-list {
252
253
  flex: 1;
253
254
  overflow-y: auto;
255
+ overflow-x: hidden;
254
256
  overscroll-behavior: contain;
255
257
  -webkit-overflow-scrolling: touch;
256
258
  scrollbar-width: thin;
@@ -270,6 +272,10 @@ function handleDayKeydown(e, dayMs) {
270
272
  align-items: center;
271
273
  gap: 12px;
272
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;
273
279
  padding: 10px 12px;
274
280
  background: transparent;
275
281
  transition: background 120ms;
@@ -24,5 +24,6 @@ export interface SwipeHandlers {
24
24
  ontouchstart: (e: TouchEvent) => void;
25
25
  ontouchmove: (e: TouchEvent) => void;
26
26
  ontouchend: () => void;
27
+ ontouchcancel: () => void;
27
28
  }
28
29
  export declare function createSwipe(cb: SwipeCallbacks): SwipeHandlers;
@@ -60,5 +60,14 @@ export function createSwipe(cb) {
60
60
  dx = 0;
61
61
  cb.onend(dir);
62
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
+ },
63
72
  };
64
73
  }
@@ -31,7 +31,8 @@ const eventSnippet = $derived(ctx.eventSnippet);
31
31
  const loadRangeCtx = $derived(ctx.loadRange);
32
32
  const clock = createClock(ctx.timezone);
33
33
  const todayMs = $derived(clock.today);
34
- const MAX_CHIPS = $derived(isMobile ? 2 : 3);
34
+ const dotsMode = $derived(isMobile);
35
+ const MAX_CHIPS = 3;
35
36
  const range = $derived(viewState?.range);
36
37
  const focusMonth = $derived((focusDate ?? /* @__PURE__ */ new Date()).getMonth());
37
38
  $effect(() => {
@@ -157,6 +158,7 @@ function chipTime(e) {
157
158
  <div
158
159
  class="mg"
159
160
  class:mg--auto={autoHeight}
161
+ class:mg--dots={dotsMode}
160
162
  style={style || undefined}
161
163
  style:height={autoHeight ? undefined : height ? `${height}px` : '100%'}
162
164
  role="grid"
@@ -223,6 +225,7 @@ function chipTime(e) {
223
225
  class:mg-chip--cancelled={ev.status === 'cancelled'}
224
226
  style:--mg-chip-color={ev.color ?? 'var(--dt-accent)'}
225
227
  title={ev.title}
228
+ aria-label="{ev.title}{chipTime(ev) ? `, ${chipTime(ev)}` : ''}"
226
229
  onclick={(e) => {
227
230
  e.stopPropagation();
228
231
  oneventclick?.(ev);
@@ -240,9 +243,10 @@ function chipTime(e) {
240
243
  type="button"
241
244
  class="mg-more"
242
245
  aria-expanded={ondayclick ? undefined : false}
246
+ aria-label={L.nMore(cell.overflow)}
243
247
  onclick={(e) => overflowClick(e, cell)}
244
248
  >
245
- {L.nMore(cell.overflow)}
249
+ {dotsMode ? `+${cell.overflow}` : L.nMore(cell.overflow)}
246
250
  </button>
247
251
  {:else if expandedMs === cell.ms}
248
252
  <button
@@ -467,4 +471,33 @@ function chipTime(e) {
467
471
  min-height: 30px;
468
472
  }
469
473
  }
474
+
475
+ /* ── Dots mode (mobile) ─────────────────────────────
476
+ Cells are too narrow for text chips, so events render as colored
477
+ dots in a wrapping row. The cell itself stays the tap target
478
+ (day drill-down); dots keep their title/aria-label for a11y. */
479
+ .mg--dots .mg-chips {
480
+ flex-direction: row;
481
+ flex-wrap: wrap;
482
+ align-items: center;
483
+ gap: 3px;
484
+ }
485
+ .mg--dots .mg-chip {
486
+ padding: 3px;
487
+ min-height: 0;
488
+ }
489
+ .mg--dots .mg-chip-title,
490
+ .mg--dots .mg-chip-time {
491
+ display: none;
492
+ }
493
+ .mg--dots .mg-chip-dot {
494
+ width: 8px;
495
+ height: 8px;
496
+ }
497
+ .mg--dots .mg-more {
498
+ padding: 0 3px;
499
+ min-height: 0;
500
+ align-self: center;
501
+ font-size: 10px;
502
+ }
470
503
  </style>
@@ -1,10 +1,6 @@
1
- <script lang="ts">import PlannerDay from "./PlannerDay.svelte";
2
- import PlannerWeek from "./PlannerWeek.svelte";
1
+ <script lang="ts">import PlannerWeek from "./PlannerWeek.svelte";
3
2
  let { mode = "week", ...rest } = $props();
4
3
  </script>
5
4
 
6
- {#if mode === 'day'}
7
- <PlannerDay {...rest} />
8
- {:else}
9
- <PlannerWeek {...rest} />
10
- {/if}
5
+ <!-- Both planner modes are the same vertical time grid; day is one column. -->
6
+ <PlannerWeek {mode} {...rest} />