@nomideusz/svelte-calendar 0.20.1 → 1.0.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.
Files changed (69) hide show
  1. package/README.md +502 -267
  2. package/dist/adapters/composite.d.ts +12 -1
  3. package/dist/adapters/composite.js +109 -47
  4. package/dist/adapters/errors.d.ts +22 -0
  5. package/dist/adapters/errors.js +32 -0
  6. package/dist/adapters/index.d.ts +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/adapters/jmap.js +26 -17
  9. package/dist/adapters/mapped.d.ts +2 -2
  10. package/dist/adapters/mapped.js +18 -9
  11. package/dist/adapters/memory.d.ts +1 -1
  12. package/dist/adapters/memory.js +5 -3
  13. package/dist/adapters/recurring.d.ts +1 -1
  14. package/dist/adapters/recurring.js +43 -25
  15. package/dist/adapters/rest.d.ts +2 -2
  16. package/dist/adapters/rest.js +9 -4
  17. package/dist/calendar/Calendar.svelte +122 -78
  18. package/dist/calendar/Calendar.svelte.d.ts +69 -14
  19. package/dist/calendar/index.d.ts +2 -2
  20. package/dist/calendar/index.js +1 -1
  21. package/dist/core/clock.svelte.js +3 -2
  22. package/dist/core/locale.d.ts +11 -1
  23. package/dist/core/locale.js +16 -16
  24. package/dist/core/time.d.ts +9 -0
  25. package/dist/core/time.js +12 -5
  26. package/dist/core/timezone.d.ts +7 -3
  27. package/dist/core/timezone.js +10 -4
  28. package/dist/engine/event-store.svelte.js +94 -57
  29. package/dist/engine/view-state.svelte.d.ts +5 -1
  30. package/dist/engine/view-state.svelte.js +8 -6
  31. package/dist/headless/create-agenda.svelte.d.ts +8 -1
  32. package/dist/headless/create-agenda.svelte.js +36 -18
  33. package/dist/headless/create-calendar.svelte.js +57 -21
  34. package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
  35. package/dist/headless/create-range-agenda.svelte.js +38 -17
  36. package/dist/headless/index.d.ts +1 -0
  37. package/dist/index.d.ts +10 -7
  38. package/dist/index.js +8 -5
  39. package/dist/primitives/DayHeader.svelte +3 -2
  40. package/dist/primitives/DayHeader.svelte.d.ts +1 -1
  41. package/dist/primitives/EmptySlot.svelte +5 -2
  42. package/dist/primitives/EventBlock.svelte +21 -14
  43. package/dist/primitives/FloatingPanel.svelte +110 -15
  44. package/dist/primitives/FloatingPanel.svelte.d.ts +7 -5
  45. package/dist/primitives/NowIndicator.svelte +11 -4
  46. package/dist/primitives/TimeGutter.svelte +10 -6
  47. package/dist/primitives/index.d.ts +1 -0
  48. package/dist/text-fit.d.ts +8 -2
  49. package/dist/views/agenda/AgendaDay.svelte +43 -35
  50. package/dist/views/agenda/AgendaWeek.svelte +75 -38
  51. package/dist/views/index.d.ts +2 -0
  52. package/dist/views/index.js +2 -0
  53. package/dist/views/mobile/MobileDay.svelte +99 -42
  54. package/dist/views/mobile/MobileWeek.svelte +31 -15
  55. package/dist/views/mobile/swipe.js +6 -1
  56. package/dist/views/month/MonthGrid.svelte +33 -21
  57. package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
  58. package/dist/views/planner/PlannerScroll.svelte +438 -164
  59. package/dist/views/planner/PlannerWeek.svelte +150 -50
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +24 -9
  62. package/widget/widget.js +10000 -9520
  63. package/dist/assets/favicon.svg +0 -9
  64. package/dist/widget/CalendarWidget.svelte +0 -114
  65. package/dist/widget/CalendarWidget.svelte.d.ts +0 -37
  66. package/dist/widget/index.d.ts +0 -1
  67. package/dist/widget/index.js +0 -1
  68. package/dist/widget/widget.d.ts +0 -10
  69. package/dist/widget/widget.js +0 -96
@@ -9,7 +9,10 @@
9
9
  Emits: onclick, ondragstart, onresize (for future interaction wiring)
10
10
  -->
11
11
  <script lang="ts">import { fmtTime, fmtDuration, getLabels } from "../core/locale.js";
12
- const L = $derived(getLabels());
12
+ import { getContext } from "svelte";
13
+ // Inside a Calendar: its per-instance labels; standalone: the global ones.
14
+ const calendarCtx = getContext("calendar");
15
+ const L = $derived(calendarCtx?.labels ?? getLabels());
13
16
  let { event, variant = "chip", active = false, past = false, showTime = false, showDuration = false, editable = false, onclick, children } = $props();
14
17
  const accentColor = $derived(event.color || "var(--dt-accent, #2563eb)");
15
18
  const isCancelled = $derived(event.status === "cancelled");
@@ -18,10 +21,11 @@ const isFull = $derived(event.status === "full");
18
21
  const isLimited = $derived(event.status === "limited");
19
22
  const ariaLabel = $derived.by(() => {
20
23
  const t = event.title;
21
- const time = `${fmtTime(event.start)} to ${fmtTime(event.end)}`;
24
+ const time = `${fmtTime(event.start)} – ${fmtTime(event.end)}`;
22
25
  const dur = fmtDuration(event.start, event.end);
23
26
  const loc = event.location ? `, ${event.location}` : "";
24
- const statusStr = isCancelled ? ", cancelled" : isTentative ? ", tentative" : isFull ? ", full" : isLimited ? ", limited" : "";
27
+ const status = isCancelled ? L.cancelled : isTentative ? L.tentative : isFull ? L.full : isLimited ? L.limited : "";
28
+ const statusStr = status ? `, ${status}` : "";
25
29
  const activeStr = active ? `, ${L.happeningNow}` : past ? `, ${L.past}` : "";
26
30
  return `${t}${loc}, ${time}, ${dur}${statusStr}${activeStr}`;
27
31
  });
@@ -63,11 +67,7 @@ function handleKeydown(e) {
63
67
  {/each}
64
68
  </div>
65
69
  {/if}
66
- {#if isCancelled}<span class="eb-status-badge eb-cancelled-badge">Cancelled</span>{/if}
67
- {#if isTentative}<span class="eb-status-badge eb-tentative-badge">Tentative</span>{/if}
68
- {#if isFull}<span class="eb-status-badge eb-full-badge">Full</span>{/if}
69
- {#if isLimited}<span class="eb-status-badge eb-limited-badge">Limited</span>{/if}
70
- {#if active}<span class="eb-live-badge">{L.now}</span>{/if}
70
+ {@render badges()}
71
71
  </div>
72
72
  {:else}
73
73
  <!-- row -->
@@ -90,13 +90,20 @@ function handleKeydown(e) {
90
90
  {/each}
91
91
  </span>
92
92
  {/if}
93
- {#if isCancelled}<span class="eb-status-badge eb-cancelled-badge">Cancelled</span>{/if}
94
- {#if isFull}<span class="eb-status-badge eb-full-badge">Full</span>{/if}
95
- {#if isLimited}<span class="eb-status-badge eb-limited-badge">Limited</span>{/if}
96
- {#if active}<span class="eb-live-badge">{L.now}</span>{/if}
93
+ {@render badges()}
97
94
  {/if}
98
95
  {/snippet}
99
96
 
97
+ <!-- Status badges: the same set in card and row. The badge CSS uppercases
98
+ the (lower-case) status labels. -->
99
+ {#snippet badges()}
100
+ {#if isCancelled}<span class="eb-status-badge eb-cancelled-badge">{L.cancelled}</span>{/if}
101
+ {#if isTentative}<span class="eb-status-badge eb-tentative-badge">{L.tentative}</span>{/if}
102
+ {#if isFull}<span class="eb-status-badge eb-full-badge">{L.full}</span>{/if}
103
+ {#if isLimited}<span class="eb-status-badge eb-limited-badge">{L.limited}</span>{/if}
104
+ {#if active}<span class="eb-live-badge">{L.now}</span>{/if}
105
+ {/snippet}
106
+
100
107
  {#if onclick}
101
108
  <div
102
109
  class="eb eb-{variant}"
@@ -331,8 +338,8 @@ function handleKeydown(e) {
331
338
  background: color-mix(in srgb, var(--_color) 12%, transparent);
332
339
  }
333
340
  .eb-full-badge {
334
- color: #2563eb;
335
- background: color-mix(in srgb, #2563eb 15%, transparent);
341
+ color: var(--dt-accent, #2563eb);
342
+ background: color-mix(in srgb, var(--dt-accent, #2563eb) 15%, transparent);
336
343
  }
337
344
  .eb-limited-badge {
338
345
  color: var(--_color);
@@ -1,9 +1,48 @@
1
1
  <script module lang="ts">// Phone sheet scroll-lock: nested panels each take a lock; the page behind
2
2
  // unlocks when the last one closes.
3
3
  let sheetLocks = 0;
4
+ // Open panels, oldest first. Escape and the outside tap belong to the
5
+ // topmost one only, so a nested panel closes without its parent.
6
+ const openPanels = [];
7
+ // Events a panel already acted on — the next panel down must not act on
8
+ // the same keydown/pointerdown once the top one unmounts mid-dispatch.
9
+ const handled = new WeakSet();
10
+ /**
11
+ * Spend the click a closing tap synthesizes. `onclose` usually unmounts the
12
+ * panel before that click arrives, so the listener lives on `window`,
13
+ * outside the component: it eats the next click (capture phase, before any
14
+ * calendar handler) and removes itself then, on the next pointerdown, on a
15
+ * pointercancel (no click follows), or after a timeout.
16
+ */
17
+ function swallowNextClick() {
18
+ const eat = (e) => {
19
+ e.stopPropagation();
20
+ e.preventDefault();
21
+ done();
22
+ };
23
+ const done = () => {
24
+ clearTimeout(timer);
25
+ window.removeEventListener("click", eat, true);
26
+ window.removeEventListener("pointerdown", done, true);
27
+ window.removeEventListener("pointercancel", done, true);
28
+ };
29
+ const timer = setTimeout(done, 1500);
30
+ window.addEventListener("click", eat, true);
31
+ window.addEventListener("pointercancel", done, true);
32
+ // Armed after this pointerdown has finished dispatching.
33
+ setTimeout(() => window.addEventListener("pointerdown", done, true), 0);
34
+ }
35
+ export {};
4
36
  </script>
5
37
 
6
- <script lang="ts">let { title = "", anchor = {
38
+ <script lang="ts">// A non-modal window that floats over the calendar: opened beside the
39
+ // block that was clicked (right of it, left when the right edge is near,
40
+ // clamped to the viewport), dragged by its header, closed by Escape, its
41
+ // own button, or a click anywhere outside — that outside click is spent on
42
+ // closing, so a block under it is not opened by the same tap. Below 640px
43
+ // it is a bottom sheet instead.
44
+ import { untrack } from "svelte";
45
+ let { title = "", anchor = {
7
46
  x: 24,
8
47
  y: 24
9
48
  }, width = 440, theme = "", closeLabel = "Close", closeOnOutside = true, onclose, children } = $props();
@@ -28,11 +67,66 @@ function beside(a) {
28
67
  const px = right + w + EDGE <= window.innerWidth ? right : a.x - GAP - w >= EDGE ? a.x - GAP - w : a.x;
29
68
  place(px, a.y);
30
69
  }
70
+ // Where focus goes back to when the panel closes: whatever held it when
71
+ // the panel opened (or when a new anchor re-focused it).
72
+ let returnFocus = null;
73
+ // Set once the user drags the panel: from then on a size change re-clamps
74
+ // it where it is instead of jumping back beside the anchor.
75
+ let dragged = false;
31
76
  // A new anchor (the next click) re-places the panel; each run focuses it.
32
77
  $effect(() => {
78
+ dragged = false;
33
79
  beside(anchor);
80
+ const active = document.activeElement;
81
+ if (active instanceof HTMLElement && active !== document.body && !el?.contains(active)) {
82
+ returnFocus = active;
83
+ }
34
84
  el?.focus({ preventScroll: true });
35
85
  });
86
+ // Content that grows (or shrinks) after opening — a form section, loaded
87
+ // data — must stay inside the viewport; so must a resized window.
88
+ $effect(() => {
89
+ if (!el) return;
90
+ const reclamp = () => {
91
+ if (dragged) place(left, top);
92
+ else beside(anchor);
93
+ };
94
+ const ro = new ResizeObserver(reclamp);
95
+ ro.observe(el);
96
+ window.addEventListener("resize", reclamp);
97
+ return () => {
98
+ ro.disconnect();
99
+ window.removeEventListener("resize", reclamp);
100
+ };
101
+ });
102
+ // Topmost-panel stack + focus restore on close.
103
+ const myId = Symbol("floating-panel");
104
+ const isTopmost = () => openPanels[openPanels.length - 1] === myId;
105
+ $effect(() => {
106
+ const id = myId;
107
+ openPanels.push(id);
108
+ const panel = untrack(() => el);
109
+ return () => {
110
+ const i = openPanels.indexOf(id);
111
+ if (i !== -1) openPanels.splice(i, 1);
112
+ // Only pull focus back when it would otherwise be lost (inside the
113
+ // closing panel, or dropped to <body>) — not from where the user
114
+ // has since moved it.
115
+ const active = document.activeElement;
116
+ const lost = !active || active === document.body || !!panel && panel.contains(active);
117
+ const target = returnFocus;
118
+ if (lost && target && target.isConnected) {
119
+ target.focus({ preventScroll: true });
120
+ } else if (lost && target) {
121
+ // The opener may be re-rendered by the close (a view refresh);
122
+ // try once more after the DOM settles.
123
+ queueMicrotask(() => {
124
+ const a = document.activeElement;
125
+ if ((!a || a === document.body) && target.isConnected) target.focus({ preventScroll: true });
126
+ });
127
+ }
128
+ };
129
+ });
36
130
  // In sheet mode the page behind must not scroll — drags on the sheet
37
131
  // chrome, or reaching the end of the sheet body, would otherwise reach it.
38
132
  $effect(() => {
@@ -59,35 +153,36 @@ function down(e) {
59
153
  e.currentTarget.setPointerCapture(e.pointerId);
60
154
  }
61
155
  function move(e) {
62
- if (grab) place(e.clientX - grab.dx, e.clientY - grab.dy);
156
+ if (!grab) return;
157
+ dragged = true;
158
+ place(e.clientX - grab.dx, e.clientY - grab.dy);
63
159
  }
64
160
  function up() {
65
161
  grab = null;
66
162
  }
67
163
  // The outside pointerdown is swallowed here, before any calendar handler,
68
164
  // and so is the click it would synthesize — one tap closes, the next acts.
69
- let swallowClick = false;
165
+ // With nested panels only the topmost reacts: a tap outside it (even on
166
+ // the panel beneath) closes it alone.
70
167
  function outsideDown(e) {
71
168
  if (!closeOnOutside || !el || el.contains(e.target)) return;
169
+ if (handled.has(e) || !isTopmost()) return;
170
+ handled.add(e);
72
171
  e.stopPropagation();
73
172
  e.preventDefault();
74
- swallowClick = true;
173
+ swallowNextClick();
75
174
  onclose?.();
76
175
  }
77
- function outsideClick(e) {
78
- if (!swallowClick) return;
79
- swallowClick = false;
80
- e.stopPropagation();
81
- e.preventDefault();
176
+ function onKeydown(e) {
177
+ // defaultPrevented: a control inside the panel (a combobox, a menu)
178
+ // already used this Escape to close itself.
179
+ if (e.key !== "Escape" || e.defaultPrevented || handled.has(e) || !isTopmost()) return;
180
+ handled.add(e);
181
+ onclose?.();
82
182
  }
83
- export {};
84
183
  </script>
85
184
 
86
- <svelte:window
87
- onkeydown={(e) => e.key === 'Escape' && onclose?.()}
88
- onpointerdowncapture={outsideDown}
89
- onclickcapture={outsideClick}
90
- />
185
+ <svelte:window onkeydown={onKeydown} onpointerdowncapture={outsideDown} />
91
186
 
92
187
  <div
93
188
  class="fp"
@@ -1,14 +1,16 @@
1
1
  import type { Snippet } from 'svelte';
2
- type Anchor = {
2
+ /** What a FloatingPanel opens beside: a viewport rect (a clicked block's `getBoundingClientRect()`), or a bare point. */
3
+ export type FloatingPanelAnchor = {
3
4
  x: number;
4
5
  y: number;
5
6
  width?: number;
6
7
  height?: number;
7
8
  };
8
- type $$ComponentProps = {
9
+ export interface FloatingPanelProps {
9
10
  title?: string;
10
11
  /** What to open beside: the clicked block's viewport rect, or a bare point. */
11
- anchor?: Anchor;
12
+ anchor?: FloatingPanelAnchor;
13
+ /** Panel width in px (default 440). Below 640px viewport width it is a bottom sheet. */
12
14
  width?: number;
13
15
  /** The same `--dt-*` theme string a Calendar takes. */
14
16
  theme?: string;
@@ -17,7 +19,7 @@ type $$ComponentProps = {
17
19
  closeOnOutside?: boolean;
18
20
  onclose?: () => void;
19
21
  children: Snippet;
20
- };
21
- declare const FloatingPanel: import("svelte").Component<$$ComponentProps, {}, "">;
22
+ }
23
+ declare const FloatingPanel: import("svelte").Component<FloatingPanelProps, {}, "">;
22
24
  type FloatingPanel = ReturnType<typeof FloatingPanel>;
23
25
  export default FloatingPanel;
@@ -12,14 +12,19 @@
12
12
  <NowIndicator mode="badge" />
13
13
  -->
14
14
  <script lang="ts">import { getLabels } from "../core/locale.js";
15
- const L = $derived(getLabels());
15
+ import { getContext } from "svelte";
16
+ // Inside a Calendar: its per-instance labels; standalone: the global ones.
17
+ const calendarCtx = getContext("calendar");
18
+ const L = $derived(calendarCtx?.labels ?? getLabels());
16
19
  let { mode = "line", position = 0, orientation = "vertical", time = "", seconds = "", showLabel = true, color, children } = $props();
17
20
  const posStyle = $derived(orientation === "vertical" ? `left: ${position}px` : `top: ${position}px`);
18
21
  const colorVar = $derived(color ? `--ni-color: ${color}` : "");
19
22
  </script>
20
23
 
21
24
  {#if mode === 'badge'}
22
- <span class="ni-badge" style={colorVar} role="status" aria-live="polite" aria-label="{L.currentTime}: {time}">
25
+ <!-- Plain text, not a live region: the time it carries changes every
26
+ second, and a status role would read each change aloud. -->
27
+ <span class="ni-badge" style={colorVar}>
23
28
  {#if children}
24
29
  {@render children()}
25
30
  {:else}
@@ -27,7 +32,9 @@ const colorVar = $derived(color ? `--ni-color: ${color}` : "");
27
32
  {/if}
28
33
  </span>
29
34
  {:else if mode === 'dot'}
30
- <div class="ni ni-dot {orientation}" style="{posStyle}; {colorVar}" role="status" aria-label="{L.currentTime}: {time}">
35
+ <!-- Decorative: the visible time is the grid's own; hidden from AT so a
36
+ ticking label is never announced. -->
37
+ <div class="ni ni-dot {orientation}" style="{posStyle}; {colorVar}" aria-hidden="true">
31
38
  <div class="ni-dot-circle"></div>
32
39
  {#if showLabel && time}
33
40
  <div class="ni-label">
@@ -37,7 +44,7 @@ const colorVar = $derived(color ? `--ni-color: ${color}` : "");
37
44
  {/if}
38
45
  </div>
39
46
  {:else}
40
- <div class="ni ni-line {orientation}" style="{posStyle}; {colorVar}" role="status" aria-label="{L.currentTime}: {time}">
47
+ <div class="ni ni-line {orientation}" style="{posStyle}; {colorVar}" aria-hidden="true">
41
48
  <div class="ni-line-bar"></div>
42
49
  {#if showLabel && time}
43
50
  <div class="ni-label">
@@ -8,23 +8,26 @@
8
8
  <script lang="ts">import { HOURS } from "../core/time.js";
9
9
  import { fmtH } from "../core/locale.js";
10
10
  let { orientation = "horizontal", hourSize = 120, halfHour = true, hours = HOURS, formatHour = fmtH } = $props();
11
+ // Labels sit relative to the first hour shown: hours [6…21] start at 0px,
12
+ // not 6 hours down.
13
+ const firstHour = $derived(hours[0] ?? 0);
11
14
  </script>
12
15
 
13
16
  {#if orientation === 'horizontal'}
14
- <div class="tg tg-h" style="--tg-hour: {hourSize}px">
17
+ <div class="tg tg-h" style="--tg-hour: {hourSize}px; --tg-count: {hours.length}">
15
18
  {#each hours as h}
16
- <div class="tg-tick" style="left: {h * hourSize}px">
19
+ <div class="tg-tick" style="left: {(h - firstHour) * hourSize}px">
17
20
  <span class="tg-label">{formatHour(h)}</span>
18
21
  </div>
19
22
  {#if halfHour}
20
- <div class="tg-tick tg-tick-half" style="left: {(h + 0.5) * hourSize}px"></div>
23
+ <div class="tg-tick tg-tick-half" style="left: {(h - firstHour + 0.5) * hourSize}px"></div>
21
24
  {/if}
22
25
  {/each}
23
26
  </div>
24
27
  {:else}
25
- <div class="tg tg-v" style="--tg-hour: {hourSize}px">
28
+ <div class="tg tg-v" style="--tg-hour: {hourSize}px; --tg-count: {hours.length}">
26
29
  {#each hours as h}
27
- <div class="tg-row" style="top: {h * hourSize}px; height: {hourSize}px">
30
+ <div class="tg-row" style="top: {(h - firstHour) * hourSize}px; height: {hourSize}px">
28
31
  <span class="tg-label">{formatHour(h)}</span>
29
32
  </div>
30
33
  {/each}
@@ -41,7 +44,7 @@ let { orientation = "horizontal", hourSize = 120, halfHour = true, hours = HOURS
41
44
  /* ── Horizontal ── */
42
45
  .tg-h {
43
46
  height: 100%;
44
- width: calc(24 * var(--tg-hour));
47
+ width: calc(var(--tg-count, 24) * var(--tg-hour));
45
48
  }
46
49
  .tg-tick {
47
50
  position: absolute;
@@ -66,6 +69,7 @@ let { orientation = "horizontal", hourSize = 120, halfHour = true, hours = HOURS
66
69
  /* ── Vertical ── */
67
70
  .tg-v {
68
71
  width: 48px;
72
+ height: calc(var(--tg-count, 24) * var(--tg-hour));
69
73
  flex-shrink: 0;
70
74
  }
71
75
  .tg-row {
@@ -4,3 +4,4 @@ export { default as TimeGutter } from './TimeGutter.svelte';
4
4
  export { default as DayHeader } from './DayHeader.svelte';
5
5
  export { default as EmptySlot } from './EmptySlot.svelte';
6
6
  export { default as FloatingPanel } from './FloatingPanel.svelte';
7
+ export type { FloatingPanelAnchor, FloatingPanelProps } from './FloatingPanel.svelte';
@@ -1,6 +1,12 @@
1
- import { type PrepareOptions } from '@chenglou/pretext';
2
1
  import type { Attachment } from 'svelte/attachments';
3
- export declare function lineCount(text: string, font: string, width: number, options?: PrepareOptions): number;
2
+ /** How text wraps when measured — the CSS properties of the same names. */
3
+ export interface TextLayoutOptions {
4
+ whiteSpace?: 'normal' | 'pre-wrap';
5
+ wordBreak?: 'normal' | 'keep-all';
6
+ /** Extra px between characters */
7
+ letterSpacing?: number;
8
+ }
9
+ export declare function lineCount(text: string, font: string, width: number, options?: TextLayoutOptions): number;
4
10
  export declare function fits(text: string, font: string, width: number, lines?: number): boolean;
5
11
  /** Height the text will take at `width`, before it is in the DOM. */
6
12
  export declare function textHeight(text: string, font: string, width: number, lineHeight: number): number;
@@ -12,11 +12,12 @@
12
12
  * Clean numbered schedule list.
13
13
  */
14
14
  import { createClock } from "../../core/clock.svelte.js";
15
- import { sod, DAY_MS, dayNum, isAllDay, isMultiDay } from "../../core/time.js";
15
+ import { sod, addDaysMs, dayNum, isAllDay } from "../../core/time.js";
16
16
  import { weekdayLong, monthLong } from "../../core/locale.js";
17
17
  import { useCalendarContext } from "../shared/context.svelte.js";
18
18
  import EventContent from "../shared/EventContent.svelte";
19
19
  import { fmtTime, duration, timeUntilMs, progress, groupIntoSlots } from "../shared/format.js";
20
+ import { createSwipe } from "../mobile/swipe.js";
20
21
  const ctx = useCalendarContext();
21
22
  const L = $derived(ctx.labels);
22
23
  const emptySnippet = $derived(ctx.emptySnippet);
@@ -36,28 +37,28 @@ const compact = $derived(ctx.compact);
36
37
  const oneventhover = $derived(ctx.oneventhover);
37
38
  const disabledSet = $derived(ctx.disabledSet);
38
39
  // ── Swipe navigation (mobile, touch only) ──────────
39
- let swipeStartX = 0;
40
- let swipeStartY = 0;
41
- let swipeActive = false;
42
- const SWIPE_THRESHOLD = 50;
43
- function onPointerDown(e) {
44
- if (!isMobile || e.pointerType !== "touch") return;
45
- swipeActive = true;
46
- swipeStartX = e.clientX;
47
- swipeStartY = e.clientY;
48
- }
49
- function onPointerUp(e) {
50
- if (!swipeActive || e.pointerType !== "touch") return;
51
- swipeActive = false;
52
- const dx = e.clientX - swipeStartX;
53
- const dy = e.clientY - swipeStartY;
54
- if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.4) {
55
- if (dx > 0) viewState?.prev();
56
- else viewState?.next();
40
+ // Touch events, not pointer events: once the browser claims a touch for
41
+ // panning it sends pointercancel and the pointerup never arrives.
42
+ const swipe = createSwipe({
43
+ disabled: () => !isMobile,
44
+ onmove: () => {},
45
+ onend: (dir) => {
46
+ if (dir > 0) viewState?.prev();
47
+ else if (dir < 0) viewState?.next();
57
48
  }
49
+ });
50
+ // ── Status suffix (aria) ────────────────────────────
51
+ function statusText(ev) {
52
+ if (ev.status === "cancelled") return ` (${L.cancelled})`;
53
+ if (ev.status === "tentative") return ` (${L.tentative})`;
54
+ if (ev.status === "full") return ` (${L.full})`;
55
+ if (ev.status === "limited") return ` (${L.limited})`;
56
+ return "";
58
57
  }
59
- function onPointerCancel() {
60
- swipeActive = false;
58
+ /** All-day events and timed ones at least a (wall-clock) day long go to
59
+ * the all-day strip; a shorter overnight event is listed with its time. */
60
+ function inAllDayStrip(ev) {
61
+ return isAllDay(ev) || ev.end.getTime() >= addDaysMs(ev.start.getTime(), 1);
61
62
  }
62
63
  // ── Format helpers (delegated to shared/format.ts) ──
63
64
  const fmt = (d) => fmtTime(d, locale);
@@ -69,18 +70,18 @@ function handleClick(ev) {
69
70
  }
70
71
  // ── Day derivations ─────────────────────────────────
71
72
  const dayMs = $derived(focusDate ? sod(focusDate.getTime()) : clock.today);
72
- const dayEnd = $derived(dayMs + DAY_MS);
73
+ const dayEnd = $derived(addDaysMs(dayMs, 1));
73
74
  const isToday = $derived(dayMs === clock.today);
74
- const isTomorrow = $derived(dayMs === clock.today + DAY_MS);
75
+ const isTomorrow = $derived(dayMs === addDaysMs(clock.today, 1));
75
76
  const isPastDay = $derived(equalDays ? false : dayMs < clock.today);
76
77
  /** All events for this day, sorted chronologically */
77
78
  const dayEvents = $derived.by(() => {
78
79
  return events.filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > dayMs).sort((a, b) => a.start.getTime() - b.start.getTime());
79
80
  });
80
81
  /** All-day / multi-day events shown in a separate strip */
81
- const allDayBanner = $derived(dayEvents.filter((ev) => isAllDay(ev) || isMultiDay(ev)));
82
+ const allDayBanner = $derived(dayEvents.filter(inAllDayStrip));
82
83
  /** Timed events (non-all-day) for normal slot rendering */
83
- const timedDayEvents = $derived(dayEvents.filter((ev) => !isAllDay(ev) && !isMultiDay(ev)));
84
+ const timedDayEvents = $derived(dayEvents.filter((ev) => !inAllDayStrip(ev)));
84
85
  const dayCat = $derived.by(() => {
85
86
  const now = clock.tick;
86
87
  const past = [];
@@ -118,7 +119,7 @@ const visibleDone = $derived(showAllDone ? dayCat.past : dayCat.past.slice(-DONE
118
119
  const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.length - DONE_VISIBLE));
119
120
  </script>
120
121
 
121
- <!-- svelte-ignore a11y_no_static_element_interactions -->
122
+ <!-- svelte-ignore a11y_no_static_element_interactions (touch swipe only; no pointer/keyboard role) -->
122
123
  <div
123
124
  class="ag ag--day"
124
125
  class:ag--disabled={disabledSet.has(dayMs)}
@@ -126,9 +127,10 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
126
127
  class:ag--auto={autoHeight}
127
128
  style={style || undefined}
128
129
  style:height={height ? `${height}px` : undefined}
129
- onpointerdown={onPointerDown}
130
- onpointerup={onPointerUp}
131
- onpointercancel={onPointerCancel}
130
+ ontouchstart={swipe.ontouchstart}
131
+ ontouchmove={swipe.ontouchmove}
132
+ ontouchend={swipe.ontouchend}
133
+ ontouchcancel={swipe.ontouchcancel}
132
134
  >
133
135
  <div class="ag-body" role="group" aria-label={L.todaysLineup}>
134
136
  <!-- ─── In-view date header (only when the chrome doesn't label the day) ─── -->
@@ -154,7 +156,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
154
156
  class="ag-allday-chip"
155
157
  class:ag-allday-chip--selected={selectedEventId === ev.id}
156
158
  style:--ev-color={ev.color || 'var(--dt-accent)'}
157
- aria-label="{ev.title}, {L.allDay}"
159
+ aria-label="{ev.title}{statusText(ev)}, {L.allDay}"
158
160
  onclick={() => handleClick(ev)}
159
161
  onpointerenter={() => oneventhover?.(ev)}
160
162
  >
@@ -182,7 +184,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
182
184
  class:ag-compact-row--cancelled={ev.status === 'cancelled'}
183
185
  class:ag-compact-row--tentative={ev.status === 'tentative'}
184
186
  style:--ev-color={ev.color || 'var(--dt-accent)'}
185
- aria-label="{ev.title}, {fmt(ev.start)}, {duration(ev)}"
187
+ aria-label="{ev.title}{statusText(ev)}, {fmt(ev.start)}, {duration(ev)}"
186
188
  onclick={() => handleClick(ev)}
187
189
  onpointerenter={() => oneventhover?.(ev)}
188
190
  >
@@ -277,7 +279,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
277
279
  class="ag-compact-row ag-compact-row--queue"
278
280
  class:ag-compact-row--selected={selectedEventId === ev.id}
279
281
  style:--ev-color={ev.color || 'var(--dt-accent)'}
280
- aria-label="{ev.title}, {fmt(ev.start)}, {duration(ev)}"
282
+ aria-label="{ev.title}{statusText(ev)}, {fmt(ev.start)}, {duration(ev)}"
281
283
  onclick={() => handleClick(ev)}
282
284
  onpointerenter={() => oneventhover?.(ev)}
283
285
  >
@@ -298,7 +300,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
298
300
  class:ag-card--hero={i === 0}
299
301
  class:ag-card--selected={selectedEventId === ev.id}
300
302
  style:--ev-color={ev.color || 'var(--dt-accent)'}
301
- aria-label="{ev.title}, {fmt(ev.start)}, {duration(ev)}"
303
+ aria-label="{ev.title}{statusText(ev)}, {fmt(ev.start)}, {duration(ev)}"
302
304
  onclick={() => handleClick(ev)}
303
305
  onpointerenter={() => oneventhover?.(ev)}
304
306
  >
@@ -345,7 +347,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
345
347
  class="ag-log-row"
346
348
  class:ag-log-row--selected={selectedEventId === ev.id}
347
349
  style:--ev-color={ev.color || 'var(--dt-accent)'}
348
- aria-label="{ev.title}, {fmt(ev.start)} to {fmt(ev.end)}"
350
+ aria-label="{ev.title}{statusText(ev)}, {fmt(ev.start)} – {fmt(ev.end)}"
349
351
  onclick={() => handleClick(ev)}
350
352
  onpointerenter={() => oneventhover?.(ev)}
351
353
  >
@@ -378,7 +380,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
378
380
  class:ag-card--full={ev.status === 'full'}
379
381
  class:ag-card--limited={ev.status === 'limited'}
380
382
  style:--ev-color={ev.color || 'var(--dt-accent)'}
381
- 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)}"
383
+ aria-label="{ev.title}{statusText(ev)}, {fmt(ev.start)} – {fmt(ev.end)}, {duration(ev)}"
382
384
  onclick={() => handleClick(ev)}
383
385
  onpointerenter={() => oneventhover?.(ev)}
384
386
  >
@@ -767,6 +769,12 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
767
769
  }
768
770
  /* Mobile: stack queue columns vertically — "Up next" (hero) first,
769
771
  Now/Done status column second */
772
+ /* Horizontal swipes are ours (day navigation); the browser keeps
773
+ vertical scrolling and pinch-zoom. */
774
+ .ag--mobile,
775
+ .ag--mobile .ag-body {
776
+ touch-action: pan-y pinch-zoom;
777
+ }
770
778
  .ag--mobile .ag-q {
771
779
  grid-template-columns: 1fr;
772
780
  min-height: auto;