@nomideusz/svelte-calendar 0.21.0 → 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 -269
  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 +37 -19
  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 +12 -12
  24. package/dist/core/time.d.ts +9 -0
  25. package/dist/core/time.js +8 -0
  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 +93 -56
  29. package/dist/engine/view-state.svelte.d.ts +5 -1
  30. package/dist/engine/view-state.svelte.js +5 -3
  31. package/dist/headless/create-agenda.svelte.d.ts +8 -1
  32. package/dist/headless/create-agenda.svelte.js +32 -14
  33. package/dist/headless/create-calendar.svelte.js +53 -17
  34. package/dist/headless/create-range-agenda.svelte.d.ts +7 -0
  35. package/dist/headless/create-range-agenda.svelte.js +33 -12
  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 +41 -33
  50. package/dist/views/agenda/AgendaWeek.svelte +71 -34
  51. package/dist/views/index.d.ts +2 -0
  52. package/dist/views/index.js +2 -0
  53. package/dist/views/mobile/MobileDay.svelte +98 -41
  54. package/dist/views/mobile/MobileWeek.svelte +29 -13
  55. package/dist/views/mobile/swipe.js +6 -1
  56. package/dist/views/month/MonthGrid.svelte +29 -17
  57. package/dist/views/month/MonthGrid.svelte.d.ts +1 -1
  58. package/dist/views/planner/PlannerScroll.svelte +183 -51
  59. package/dist/views/planner/PlannerWeek.svelte +148 -48
  60. package/dist/views/shared/context.svelte.d.ts +1 -1
  61. package/package.json +24 -9
  62. package/widget/widget.js +7983 -7688
  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
@@ -32,29 +32,50 @@
32
32
  */
33
33
  import { untrack } from 'svelte';
34
34
  import { createEventStore } from '../engine/event-store.svelte.js';
35
- import { sod, addDaysMs } from '../core/time.js';
35
+ import { sod, addDaysMs, overlapsRange } from '../core/time.js';
36
+ import { toZonedTime, nowInZone, wrapAdapterWithTimezone } from '../core/timezone.js';
36
37
  import { fmtTime as _fmtTime, fmtDuration } from '../core/locale.js';
37
38
  // ─── Implementation ─────────────────────────────────────
38
39
  export function createRangeAgenda(options) {
39
- const { initialDate, locale, days: dayCount = 7 } = options;
40
- const resolveAdapter = typeof options.adapter === 'function'
40
+ const { initialDate, locale, days: dayCount = 7, timezone } = options;
41
+ const rawAdapter = typeof options.adapter === 'function'
41
42
  ? options.adapter
42
43
  : () => options.adapter;
43
- const store = $derived(createEventStore(resolveAdapter()));
44
+ const resolveAdapter = timezone
45
+ ? () => wrapAdapterWithTimezone(rawAdapter(), timezone)
46
+ : rawAdapter;
47
+ /** An instant on the plane the days are drawn on (zoned wall-clock with `timezone`). */
48
+ const plane = (ms) => (timezone ? toZonedTime(ms, timezone).getTime() : ms);
49
+ const nowMs = () => (timezone ? nowInZone(timezone).getTime() : Date.now());
50
+ // One store for the agenda's life: a getter adapter that changes identity
51
+ // reloads it instead of rebuilding it empty (every row blinking out).
52
+ const store = createEventStore(resolveAdapter);
44
53
  // ── Window start (reactive, writable) ──
45
- let startMs = $state(sod(initialDate?.getTime() ?? Date.now()));
54
+ let startMs = $state(sod(initialDate ? plane(initialDate.getTime()) : nowMs()));
46
55
  const endMs = $derived(addDaysMs(startMs, dayCount));
47
56
  // ── Load events for the window (re-runs on paging / adapter swap) ──
48
- $effect(() => {
57
+ // Eager initial load for synchronous adapters (the server render holds the
58
+ // rows); the effect's first run then has nothing new to fetch — a seeded
59
+ // adapter would otherwise send its seed range straight to the network.
60
+ let eagerKey = untrack(() => {
61
+ if (!resolveAdapter().fetchEventsSync)
62
+ return '';
49
63
  store.load({ start: new Date(startMs), end: new Date(endMs) });
64
+ return `${startMs}-${endMs}`;
50
65
  });
51
- // Eager initial load
52
- untrack(() => {
66
+ $effect(() => {
67
+ rawAdapter(); // a new adapter reloads
68
+ const key = `${startMs}-${endMs}`;
69
+ if (key === eagerKey) {
70
+ eagerKey = '';
71
+ return;
72
+ }
73
+ eagerKey = '';
53
74
  store.load({ start: new Date(startMs), end: new Date(endMs) });
54
75
  });
55
76
  // ── Day derivations ──
56
77
  const days = $derived.by(() => {
57
- const todayMs = sod(Date.now());
78
+ const todayMs = sod(nowMs());
58
79
  const events = store.events;
59
80
  return Array.from({ length: dayCount }, (_, i) => {
60
81
  const ms = addDaysMs(startMs, i);
@@ -69,7 +90,7 @@ export function createRangeAgenda(options) {
69
90
  isToday: ms === todayMs,
70
91
  isPast: dayEnd <= todayMs,
71
92
  events: events
72
- .filter((ev) => ev.start.getTime() < dayEnd && ev.end.getTime() > ms)
93
+ .filter((ev) => overlapsRange(ev, date, new Date(dayEnd)))
73
94
  .sort((a, b) => a.start.getTime() - b.start.getTime()),
74
95
  };
75
96
  });
@@ -98,10 +119,10 @@ export function createRangeAgenda(options) {
98
119
  startMs = addDaysMs(startMs, dayCount);
99
120
  },
100
121
  goToday() {
101
- startMs = sod(Date.now());
122
+ startMs = sod(nowMs());
102
123
  },
103
124
  setDate(date) {
104
- startMs = sod(date.getTime());
125
+ startMs = sod(plane(date.getTime()));
105
126
  },
106
127
  refresh() {
107
128
  void store.load({ start: new Date(startMs), end: new Date(endMs) });
@@ -4,3 +4,4 @@ export type { AgendaOptions, HeadlessAgenda } from './create-agenda.svelte.js';
4
4
  export { createRangeAgenda } from './create-range-agenda.svelte.js';
5
5
  export type { RangeAgendaOptions, RangeAgendaDay, HeadlessRangeAgenda, } from './create-range-agenda.svelte.js';
6
6
  export type { HeadlessCalendarOptions, HeadlessCalendar, HeadlessDay, HeadlessWeek, TodayQueue, HeaderContext, NavigationContext, } from './types.js';
7
+ export type { TimeSlot } from '../views/shared/format.js';
package/dist/index.d.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  export { NowIndicator, EventBlock, TimeGutter, DayHeader, EmptySlot, FloatingPanel, } from './primitives/index.js';
2
- export { Calendar } from './calendar/index.js';
3
- export { Planner, Agenda, Mobile } from './views/index.js';
4
- export { default as MonthGrid } from './views/month/MonthGrid.svelte';
5
- export type { CalendarView } from './calendar/index.js';
2
+ export type { FloatingPanelAnchor, FloatingPanelProps } from './primitives/index.js';
3
+ export { Calendar, defaultViews } from './calendar/index.js';
4
+ export type { CalendarView, CalendarViewProps, CalendarProps } from './calendar/index.js';
5
+ export { Planner, PlannerScroll, Agenda, Mobile, MonthGrid } from './views/index.js';
6
+ export { useCalendarContext } from './views/shared/context.svelte.js';
7
+ export type { CalendarContext } from './views/shared/context.svelte.js';
6
8
  export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
7
9
  export type { EventStore, ViewState, ViewStateOptions, CalendarViewId, BuiltInViewId, ViewMode, Selection as CalendarSelection, DragState, DragMode, DragPayload, } from './engine/index.js';
8
10
  export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
11
+ export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './adapters/index.js';
9
12
  export type { CalendarAdapter, WritableCalendarAdapter, DateRange, MemoryAdapterOptions, RestAdapterOptions, RecurringEvent, RecurringAdapterOptions, FieldMapping, MappedAdapterOptions, MutationHandler, CompositeAdapterOptions, JmapClient, JmapCalendarAdapterOptions, } from './adapters/index.js';
10
- export { createClock, startOfWeek, fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
13
+ export { createClock, sod, startOfWeek, addDaysMs, diffDays, fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
11
14
  export type { Clock, TimelineEvent, BlockedSlot, DaySegment, CalendarLabels, EventStatus, } from './core/index.js';
12
15
  export { auto, neutral, midnight, presets } from './theme/index.js';
13
16
  export { probeHostTheme, observeHostTheme } from './theme/index.js';
14
17
  export { wrapAdapterWithTimezone } from './core/timezone.js';
15
18
  export type { PresetName, AutoThemeOptions } from './theme/index.js';
16
19
  export { createCalendar, createAgenda, createRangeAgenda } from './headless/index.js';
17
- export type { HeadlessCalendarOptions, HeadlessCalendar, HeadlessDay, HeadlessWeek, TodayQueue, HeaderContext, NavigationContext, AgendaOptions, HeadlessAgenda, RangeAgendaOptions, RangeAgendaDay, HeadlessRangeAgenda, } from './headless/index.js';
20
+ export type { HeadlessCalendarOptions, HeadlessCalendar, HeadlessDay, HeadlessWeek, TodayQueue, HeaderContext, NavigationContext, AgendaOptions, HeadlessAgenda, RangeAgendaOptions, RangeAgendaDay, HeadlessRangeAgenda, TimeSlot, } from './headless/index.js';
18
21
  export { fits, lineCount, textHeight, textWidth, pickFit, fontOf, fitLabel, fitParts, breakLines, typeset } from './text-fit.js';
19
- export type { ChipPart } from './text-fit.js';
22
+ export type { ChipPart, TextLayoutOptions } from './text-fit.js';
package/dist/index.js CHANGED
@@ -1,16 +1,19 @@
1
1
  // ─── Primitives ─────────────────────────────────────────
2
2
  export { NowIndicator, EventBlock, TimeGutter, DayHeader, EmptySlot, FloatingPanel, } from './primitives/index.js';
3
3
  // ─── Calendar shell ─────────────────────────────────────
4
- export { Calendar } from './calendar/index.js';
5
- // Raw view components — compose your own shell around the engine if needed
6
- export { Planner, Agenda, Mobile } from './views/index.js';
7
- export { default as MonthGrid } from './views/month/MonthGrid.svelte';
4
+ export { Calendar, defaultViews } from './calendar/index.js';
5
+ // Raw view components — register them under your own ids, or compose your
6
+ // own shell around the engine. They read the Calendar's context.
7
+ export { Planner, PlannerScroll, Agenda, Mobile, MonthGrid } from './views/index.js';
8
+ // For custom views: the running Calendar's engines, config and labels.
9
+ export { useCalendarContext } from './views/shared/context.svelte.js';
8
10
  // ─── Engine (reactive state) ────────────────────────────
9
11
  export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
10
12
  // ─── Adapters ───────────────────────────────────────────
11
13
  export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
14
+ export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './adapters/index.js';
12
15
  // ─── Core: clock, time, locale, types ───────────────────
13
- export { createClock, startOfWeek, fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
16
+ export { createClock, sod, startOfWeek, addDaysMs, diffDays, fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
14
17
  // ─── Themes ─────────────────────────────────────────────
15
18
  export { auto, neutral, midnight, presets } from './theme/index.js';
16
19
  export { probeHostTheme, observeHostTheme } from './theme/index.js';
@@ -6,7 +6,8 @@
6
6
  - Short/long weekday + date number for week views
7
7
  -->
8
8
  <script lang="ts">import { weekdayShort, weekdayLong, monthShort, fmtDay } from "../core/locale.js";
9
- let { dayMs, todayMs = Date.now(), format = "short", isToday = false, isPast = false } = $props();
9
+ import { sod } from "../core/time.js";
10
+ let { dayMs, todayMs = sod(Date.now()), format = "short", isToday = false, isPast = false } = $props();
10
11
  const dayNum = $derived(new Date(dayMs).getDate());
11
12
  </script>
12
13
 
@@ -16,7 +17,7 @@ const dayNum = $derived(new Date(dayMs).getDate());
16
17
  class:dh-past={isPast}
17
18
  >
18
19
  {#if format === 'relative'}
19
- <span class="dh-rel">{fmtDay(dayMs, todayMs)}</span>
20
+ <span class="dh-rel">{fmtDay(sod(dayMs), sod(todayMs))}</span>
20
21
  {:else if format === 'long'}
21
22
  <span class="dh-name dh-name-long">{weekdayLong(dayMs)}</span>
22
23
  <span class="dh-date">{monthShort(dayMs)} {dayNum}</span>
@@ -1,7 +1,7 @@
1
1
  interface Props {
2
2
  /** Timestamp (ms) of the day to label */
3
3
  dayMs: number;
4
- /** Timestamp (ms) of today (for relative labels) */
4
+ /** Any timestamp (ms) on today, for relative labels — default: now */
5
5
  todayMs?: number;
6
6
  /** Display format */
7
7
  format?: 'relative' | 'short' | 'long';
@@ -6,7 +6,10 @@
6
6
  between events.
7
7
  -->
8
8
  <script lang="ts">import { fmtTime, fmtDuration, getLabels } from "../core/locale.js";
9
- const L = $derived(getLabels());
9
+ import { getContext } from "svelte";
10
+ // Inside a Calendar: its per-instance labels; standalone: the global ones.
11
+ const calendarCtx = getContext("calendar");
12
+ const L = $derived(calendarCtx?.labels ?? getLabels());
10
13
  let { start, end, onclick, orientation = "vertical" } = $props();
11
14
  const dur = $derived(`${fmtDuration(start, end)} ${L.free}`);
12
15
  function handleKeydown(e) {
@@ -26,7 +29,7 @@ function handleKeydown(e) {
26
29
  class:es-h={orientation === 'horizontal'}
27
30
  role="button"
28
31
  tabindex="0"
29
- aria-label="{L.createEvent}, {fmtTime(start)} to {fmtTime(end)}, {dur}"
32
+ aria-label="{L.createEvent}, {fmtTime(start)} – {fmtTime(end)}, {dur}"
30
33
  onclick={() => onclick?.({ start, end })}
31
34
  onkeydown={handleKeydown}
32
35
  >
@@ -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;