@nomideusz/svelte-calendar 0.8.0 → 0.10.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 (37) hide show
  1. package/README.md +29 -8
  2. package/dist/calendar/Calendar.svelte +187 -64
  3. package/dist/calendar/Calendar.svelte.d.ts +14 -0
  4. package/dist/core/clock.svelte.d.ts +1 -1
  5. package/dist/core/clock.svelte.js +8 -4
  6. package/dist/core/locale.d.ts +15 -1
  7. package/dist/core/locale.js +9 -2
  8. package/dist/core/timezone.d.ts +12 -0
  9. package/dist/core/timezone.js +31 -0
  10. package/dist/headless/create-calendar.svelte.js +12 -6
  11. package/dist/headless/types.d.ts +2 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/theme/auto.js +27 -6
  15. package/dist/theme/presets.d.ts +4 -4
  16. package/dist/theme/presets.js +11 -7
  17. package/dist/views/agenda/AgendaDay.svelte +260 -111
  18. package/dist/views/agenda/AgendaWeek.svelte +191 -66
  19. package/dist/views/mobile/MobileDay.svelte +409 -214
  20. package/dist/views/mobile/MobileWeek.svelte +137 -75
  21. package/dist/views/mobile/swipe.d.ts +28 -0
  22. package/dist/views/mobile/swipe.js +64 -0
  23. package/dist/views/month/MonthGrid.svelte +167 -31
  24. package/dist/views/planner/PlannerDay.svelte +156 -81
  25. package/dist/views/planner/PlannerWeek.svelte +1156 -631
  26. package/dist/views/planner/PlannerWeek.svelte.d.ts +2 -0
  27. package/dist/views/shared/context.svelte.d.ts +4 -0
  28. package/dist/views/shared/context.svelte.js +14 -9
  29. package/dist/views/shared/format.d.ts +2 -1
  30. package/dist/views/shared/format.js +8 -5
  31. package/dist/widget/CalendarWidget.svelte +33 -5
  32. package/dist/widget/CalendarWidget.svelte.d.ts +16 -2
  33. package/dist/widget/widget.d.ts +9 -0
  34. package/dist/widget/widget.js +59 -13
  35. package/package.json +1 -1
  36. package/widget/widget.js +3830 -2755
  37. package/widget/svelte-calendar.css +0 -3054
package/README.md CHANGED
@@ -657,19 +657,25 @@ The `locale` prop controls date/time formatting (BCP 47):
657
657
 
658
658
  `setDefaultLocale('de-DE')` sets the locale globally instead of per component (`getDefaultLocale()` reads it back, `is24HourLocale(tag)` tells you how times will format).
659
659
 
660
- Override UI labels for full translation:
660
+ Override UI labels per calendar with the `labels` prop — it's reactive (swap it to switch language live) and instance-scoped (two calendars on one page can carry different languages):
661
661
 
662
- ```ts
663
- import { setLabels } from '@nomideusz/svelte-calendar';
664
-
665
- setLabels({
662
+ ```svelte
663
+ <Calendar {adapter} locale="de-DE" labels={{
666
664
  today: 'Heute', day: 'Tag', week: 'Woche',
667
665
  noEvents: 'Keine Termine',
668
666
  nMore: (n) => `+${n} weitere`,
669
- });
667
+ }} />
670
668
  ```
671
669
 
672
- Call `resetLabels()` to restore English defaults (`defaultLabels` exports them; `getLabels()` reads the active set).
670
+ Or set labels globally before mount:
671
+
672
+ ```ts
673
+ import { setLabels } from '@nomideusz/svelte-calendar';
674
+
675
+ setLabels({ today: 'Heute', day: 'Tag', week: 'Woche' });
676
+ ```
677
+
678
+ The `labels` prop merges over the global set. Call `resetLabels()` to restore English defaults (`defaultLabels` exports them; `getLabels()` reads the active set). Note: global `setLabels()` calls after mount don't re-render already-mounted calendars — use the `labels` prop for dynamic language switching. Standalone primitives (`DayHeader`, `EventBlock`, …) always read the global set.
673
679
 
674
680
  <details>
675
681
  <summary>All label keys</summary>
@@ -706,7 +712,20 @@ Call `resetLabels()` to restore English defaults (`defaultLabels` exports them;
706
712
 
707
713
  ## Timezone Support
708
714
 
709
- Convert events between timezones using the built-in helpers:
715
+ Render the whole calendar in any IANA timezone — events, the now-indicator
716
+ and day boundaries all shift; ranges passed to `oneventcreate`/`oneventmove`
717
+ convert back to real instants:
718
+
719
+ ```svelte
720
+ <Calendar {adapter} timezone="Europe/Warsaw" />
721
+ ```
722
+
723
+ Under the hood the adapter is wrapped with `wrapAdapterWithTimezone` (also
724
+ exported) so views do plain local-time math on a zoned wall-clock plane.
725
+ Known limit shared by every wall-clock calendar: the repeated hour of a DST
726
+ fall-back is ambiguous, writes inside it resolve to one of the two instants.
727
+
728
+ Convert events manually with the built-in helpers:
710
729
 
711
730
  ```ts
712
731
  import { toZonedTime, fromZonedTime, nowInZone } from '@nomideusz/svelte-calendar';
@@ -771,6 +790,8 @@ Drop into any HTML page — no build tools needed. Registers a `<day-calendar>`
771
790
  ></day-calendar>
772
791
  ```
773
792
 
793
+ The widget renders inside shadow DOM: host-page CSS (resets, theme stylesheets) cannot affect the calendar and calendar styles never leak out, while the `auto` theme still probes the host page's colors and fonts across the shadow boundary.
794
+
774
795
  | Attribute | Description |
775
796
  |-----------|-------------|
776
797
  | `api` | REST endpoint — fetched as `GET {api}?start=...&end=...` |
@@ -23,13 +23,14 @@ import { createViewState } from "../engine/view-state.svelte.js";
23
23
  import { createSelection } from "../engine/selection.svelte.js";
24
24
  import { createDragState } from "../engine/drag.svelte.js";
25
25
  import { onMount } from "svelte";
26
- import { getLabels } from "../core/locale.js";
26
+ import { getLabels, fmtWeekRange } from "../core/locale.js";
27
27
  import { auto } from "../theme/presets.js";
28
28
  import { probeHostTheme, observeHostTheme } from "../theme/auto.js";
29
29
  import Planner from "../views/planner/Planner.svelte";
30
30
  import Agenda from "../views/agenda/Agenda.svelte";
31
31
  import Mobile from "../views/mobile/Mobile.svelte";
32
32
  import MonthGrid from "../views/month/MonthGrid.svelte";
33
+ import { wrapAdapterWithTimezone, toZonedTime, fromZonedTime } from "../core/timezone.js";
33
34
  const MOBILE_BREAKPOINT = 768;
34
35
  const DEFAULT_VIEWS = [
35
36
  { id: "day-planner", label: "Planner", mode: "day", component: Planner },
@@ -51,6 +52,7 @@ let {
51
52
  borderRadius = 12,
52
53
  dir,
53
54
  locale,
55
+ labels: labelsProp,
54
56
  readOnly = false,
55
57
  visibleHours,
56
58
  initialDate,
@@ -80,15 +82,23 @@ let {
80
82
  ondatechange,
81
83
  oneventhover,
82
84
  ondayclick,
83
- onerror
85
+ onerror,
86
+ timezone
84
87
  } = $props();
85
- const effectiveCreate = $derived(readOnly ? void 0 : oneventcreate);
86
- const effectiveMove = $derived(readOnly ? void 0 : oneventmove);
88
+ const unzone = (d) => timezone ? fromZonedTime(d, timezone) : d;
89
+ const effectiveCreate = $derived(
90
+ readOnly || !oneventcreate ? void 0 : (range) => oneventcreate({ start: unzone(range.start), end: unzone(range.end) })
91
+ );
92
+ const effectiveMove = $derived(
93
+ readOnly || !oneventmove ? void 0 : (ev, start, end) => oneventmove(ev, unzone(start), unzone(end))
94
+ );
87
95
  function handleEventClick(ev) {
88
96
  selection.select(ev.id);
89
97
  oneventclick?.(ev);
90
98
  }
91
- let containerWidth = $state(0);
99
+ let containerWidth = $state(
100
+ typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? MOBILE_BREAKPOINT - 1 : 0
101
+ );
92
102
  const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
93
103
  const useMobile = $derived(
94
104
  mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp)
@@ -114,12 +124,17 @@ onMount(() => {
114
124
  };
115
125
  });
116
126
  const effectiveTheme = $derived(theme === auto && autoTheme !== false ? probedTheme : theme);
117
- const store = $derived(createEventStore(adapter));
127
+ const effectiveAdapter = $derived(
128
+ timezone ? wrapAdapterWithTimezone(adapter, timezone) : adapter
129
+ );
130
+ const store = $derived(createEventStore(effectiveAdapter));
118
131
  const viewState = createViewState(untrack(() => ({
119
132
  view: activeViewId ?? views[0]?.id,
120
133
  mondayStart,
121
- initialDate,
134
+ // Focus lives on the zoned plane too — day boundaries follow the zone.
135
+ initialDate: initialDate && timezone ? toZonedTime(initialDate, timezone) : initialDate,
122
136
  dayCount: days,
137
+ timezone,
123
138
  modeForView: (viewId) => views.find((v) => v.id === viewId)?.mode
124
139
  })));
125
140
  const selection = createSelection();
@@ -221,7 +236,10 @@ setContext("calendar", {
221
236
  return oneventhover;
222
237
  },
223
238
  get ondayclick() {
224
- return ondayclick;
239
+ return ondayclick ?? defaultDayClick;
240
+ },
241
+ get timezone() {
242
+ return timezone;
225
243
  },
226
244
  // Config (reactive via getters)
227
245
  get readOnly() {
@@ -272,6 +290,9 @@ setContext("calendar", {
272
290
  get compact() {
273
291
  return compact;
274
292
  },
293
+ get labels() {
294
+ return mergedLabels;
295
+ },
275
296
  // Load range (read/write)
276
297
  get loadRange() {
277
298
  return viewLoadRange;
@@ -332,6 +353,13 @@ const dateLabel = $derived.by(() => {
332
353
  day: "numeric"
333
354
  });
334
355
  }
356
+ if (viewState.mode === "week") {
357
+ return fmtWeekRange(
358
+ viewState.range.start.getTime(),
359
+ locale,
360
+ viewState.range.end.getTime() - 1
361
+ );
362
+ }
335
363
  return viewState.focusDate.toLocaleDateString(locale, {
336
364
  month: "long",
337
365
  year: "numeric"
@@ -341,19 +369,77 @@ const modes = $derived.by(() => {
341
369
  const g = new Set(desktopViews.map((v) => v.mode));
342
370
  return ["day", "week", "month"].filter((key) => g.has(key));
343
371
  });
344
- const L = $derived(getLabels());
372
+ const mergedLabels = $derived(
373
+ labelsProp ? { ...getLabels(), ...labelsProp } : getLabels()
374
+ );
375
+ const L = $derived(mergedLabels);
376
+ let lastViewLabel = $state(void 0);
377
+ $effect(() => {
378
+ const current = views.find((v) => v.id === viewState.view);
379
+ if (current && current.mode !== "month") lastViewLabel = current.label;
380
+ });
345
381
  function switchMode(g) {
346
- const currentLabel = desktopViews.find((v) => v.id === viewState.view)?.label ?? activeView?.label;
347
- const match = desktopViews.find((v) => v.mode === g && v.label === currentLabel);
382
+ const currentView = desktopViews.find((v) => v.id === viewState.view) ?? activeView;
383
+ const preferredLabel = currentView?.mode === "month" ? lastViewLabel ?? currentView?.label : currentView?.label;
384
+ const match = desktopViews.find((v) => v.mode === g && v.label === preferredLabel);
348
385
  const fallback = desktopViews.find((v) => v.mode === g);
349
386
  const target = match ?? fallback;
350
387
  if (target) viewState.setView(target.id);
351
388
  }
389
+ const labelsForMode = $derived.by(() => {
390
+ const seen = [];
391
+ for (const v of desktopViews) {
392
+ if (v.mode === viewState.mode && !seen.includes(v.label)) seen.push(v.label);
393
+ }
394
+ return seen;
395
+ });
396
+ function switchLabel(label) {
397
+ const target = desktopViews.find((v) => v.mode === viewState.mode && v.label === label);
398
+ if (target) viewState.setView(target.id);
399
+ }
400
+ const defaultDayClick = $derived.by(() => {
401
+ const target = desktopViews.find((v) => v.mode === "day" && v.label === lastViewLabel) ?? desktopViews.find((v) => v.mode === "day");
402
+ if (!target) return void 0;
403
+ return (date) => {
404
+ viewState.setFocusDate(date);
405
+ viewState.setView(target.id);
406
+ };
407
+ });
352
408
  const viewIncludesToday = $derived.by(() => {
353
- const now = Date.now();
409
+ const now = /* @__PURE__ */ new Date();
410
+ if (viewState.mode === "month") {
411
+ const f = viewState.focusDate;
412
+ return f.getMonth() === now.getMonth() && f.getFullYear() === now.getFullYear();
413
+ }
354
414
  const { start, end } = viewState.range;
355
- return now >= start.getTime() && now < end.getTime();
415
+ return now.getTime() >= start.getTime() && now.getTime() < end.getTime();
356
416
  });
417
+ const resolvedDir = $derived.by(() => {
418
+ if (dir) return dir;
419
+ if (!locale) return void 0;
420
+ try {
421
+ const info = new Intl.Locale(locale);
422
+ const direction = info.textInfo?.direction ?? info.getTextInfo?.().direction;
423
+ return direction === "rtl" ? "rtl" : void 0;
424
+ } catch {
425
+ return void 0;
426
+ }
427
+ });
428
+ function handleShortcuts(e) {
429
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
430
+ const t = e.target;
431
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
432
+ if (e.key === "t" || e.key === "T") {
433
+ e.preventDefault();
434
+ viewState.goToday();
435
+ } else if (e.key === "ArrowLeft") {
436
+ e.preventDefault();
437
+ viewState.prev();
438
+ } else if (e.key === "ArrowRight") {
439
+ e.preventDefault();
440
+ viewState.next();
441
+ }
442
+ }
357
443
  const headerCtx = $derived({
358
444
  dateLabel,
359
445
  mode: viewState.mode,
@@ -375,6 +461,7 @@ const navCtx = $derived({
375
461
  });
376
462
  </script>
377
463
 
464
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -- shortcut keys (t/←/→) act on events bubbling from the calendar's focusable controls; the region itself is not a tab stop -->
378
465
  <div
379
466
  class="cal"
380
467
  bind:this={calEl}
@@ -382,8 +469,10 @@ const navCtx = $derived({
382
469
  class:cal--auto={heightProp === 'auto'}
383
470
  role="region"
384
471
  aria-label={L.calendar}
385
- dir={dir}
472
+ aria-busy={store.loading || undefined}
473
+ dir={resolvedDir}
386
474
  lang={locale}
475
+ onkeydown={handleShortcuts}
387
476
  >
388
477
  <!-- ─── Custom header snippet (replaces all chrome) ─── -->
389
478
  {#if headerSnippet}
@@ -393,21 +482,15 @@ const navCtx = $derived({
393
482
  {:else if useMobile && (showNavigation || (showModePills && modes.length > 1) || dateLabel)}
394
483
  <div class="cal-m-hd">
395
484
  <div class="cal-m-left">
396
- {#if navigationSnippet}
397
- {@render navigationSnippet(navCtx)}
398
- {:else if showNavigation}
399
- <button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
400
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>
401
- </button>
402
- {/if}
403
-
404
485
  {#if showModePills && modes.length > 1}
405
- <div class="cal-m-pills" role="group" aria-label={L.viewMode}>
406
- {#each modes as g}
486
+ <div class="cal-m-pills" role="radiogroup" aria-label={L.viewMode}>
487
+ {#each modes as g (g)}
407
488
  <button
489
+ type="button"
408
490
  class="cal-m-pill"
409
491
  class:cal-m-pill--active={viewState.mode === g}
410
- aria-pressed={viewState.mode === g}
492
+ role="radio"
493
+ aria-checked={viewState.mode === g}
411
494
  onclick={() => switchMode(g)}
412
495
  >
413
496
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -417,26 +500,32 @@ const navCtx = $derived({
417
500
  {/if}
418
501
  </div>
419
502
 
420
- <span class="cal-m-title">{dateLabel}</span>
503
+ <span class="cal-m-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
421
504
 
422
505
  <div class="cal-m-right">
423
- {#if !navigationSnippet && showNavigation}
424
- <button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
506
+ {#if navigationSnippet}
507
+ {@render navigationSnippet(navCtx)}
508
+ {:else if showNavigation}
509
+ <!-- Always rendered (disabled on today) so navigating never shifts layout -->
510
+ <button
511
+ type="button"
512
+ class="cal-m-today"
513
+ onclick={() => viewState.goToday()}
514
+ disabled={viewIncludesToday}
515
+ title={L.goToToday}
516
+ >
517
+ {L.today}
518
+ </button>
519
+ <button type="button" class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
520
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>
521
+ </button>
522
+ <button type="button" class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
425
523
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
426
524
  </button>
427
525
  {/if}
428
526
  </div>
429
527
  </div>
430
528
 
431
- <!-- Today pill — floats below header, doesn't affect header flow -->
432
- {#if !navigationSnippet && showNavigation && !viewIncludesToday}
433
- <div class="cal-m-today-bar">
434
- <button class="cal-m-today" onclick={() => viewState.goToday()}>
435
- {L.today}
436
- </button>
437
- </div>
438
- {/if}
439
-
440
529
  <!-- ─── Desktop header ─── -->
441
530
  {:else if showNavigation || (showModePills && modes.length > 1) || dateLabel}
442
531
  <div class="cal-hd">
@@ -444,28 +533,52 @@ const navCtx = $derived({
444
533
  {#if navigationSnippet}
445
534
  {@render navigationSnippet(navCtx)}
446
535
  {:else if showNavigation}
447
- {#if !viewIncludesToday}
448
- <button class="cal-hd-today" onclick={() => viewState.goToday()} title={L.goToToday}>
449
- {L.today}
450
- </button>
451
- {/if}
452
- <button class="cal-hd-btn" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
536
+ <!-- Always rendered (disabled on today) so navigating never shifts the centered title -->
537
+ <button
538
+ type="button"
539
+ class="cal-hd-today"
540
+ onclick={() => viewState.goToday()}
541
+ disabled={viewIncludesToday}
542
+ title={L.goToToday}
543
+ >
544
+ {L.today}
545
+ </button>
546
+ <button type="button" class="cal-hd-btn" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
453
547
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>
454
548
  </button>
455
- <button class="cal-hd-btn" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
549
+ <button type="button" class="cal-hd-btn" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
456
550
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
457
551
  </button>
458
552
  {/if}
459
553
  </div>
460
- <span class="cal-hd-title">{dateLabel}</span>
554
+ <span class="cal-hd-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
461
555
  <div class="cal-hd-side cal-hd-side--end">
556
+ {#if showModePills && labelsForMode.length > 1}
557
+ <!-- View-type switcher (e.g. Planner ↔ Agenda) for the current mode -->
558
+ <div class="cal-pills cal-pills--labels" role="radiogroup" aria-label={L.viewMode}>
559
+ {#each labelsForMode as label (label)}
560
+ <button
561
+ type="button"
562
+ class="cal-pill"
563
+ class:cal-pill--active={activeView?.label === label}
564
+ role="radio"
565
+ aria-checked={activeView?.label === label}
566
+ onclick={() => switchLabel(label)}
567
+ >
568
+ {label}
569
+ </button>
570
+ {/each}
571
+ </div>
572
+ {/if}
462
573
  {#if showModePills && modes.length > 1}
463
- <div class="cal-pills" role="group" aria-label={L.viewMode}>
464
- {#each modes as g}
574
+ <div class="cal-pills" role="radiogroup" aria-label={L.viewMode}>
575
+ {#each modes as g (g)}
465
576
  <button
577
+ type="button"
466
578
  class="cal-pill"
467
579
  class:cal-pill--active={viewState.mode === g}
468
- aria-pressed={viewState.mode === g}
580
+ role="radio"
581
+ aria-checked={viewState.mode === g}
469
582
  onclick={() => switchMode(g)}
470
583
  >
471
584
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -528,6 +641,7 @@ const navCtx = $derived({
528
641
  /* ── Desktop header ── */
529
642
  .cal-hd {
530
643
  display: flex;
644
+ flex-wrap: wrap;
531
645
  align-items: center;
532
646
  gap: 8px;
533
647
  padding: 8px 12px;
@@ -596,10 +710,14 @@ const navCtx = $derived({
596
710
  transition: background 120ms, color 120ms, border-color 120ms;
597
711
  }
598
712
 
599
- .cal-hd-today:hover {
713
+ .cal-hd-today:hover:not(:disabled) {
600
714
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
601
715
  border-color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
602
716
  }
717
+ .cal-hd-today:disabled {
718
+ opacity: 0.45;
719
+ cursor: default;
720
+ }
603
721
 
604
722
  .cal-pills {
605
723
  display: flex;
@@ -670,6 +788,13 @@ const navCtx = $derived({
670
788
  100% { transform: translateX(100%); }
671
789
  }
672
790
 
791
+ @media (prefers-reduced-motion: reduce) {
792
+ .cal-loading {
793
+ animation: none;
794
+ background: var(--dt-accent-dim, rgba(37, 99, 235, 0.12));
795
+ }
796
+ }
797
+
673
798
  /* ── Mobile header (flow layout) ── */
674
799
  .cal-m-hd {
675
800
  display: flex;
@@ -697,8 +822,8 @@ const navCtx = $derived({
697
822
  display: flex;
698
823
  align-items: center;
699
824
  justify-content: center;
700
- width: 32px;
701
- height: 32px;
825
+ width: 40px;
826
+ height: 40px;
702
827
  border: none;
703
828
  background: transparent;
704
829
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
@@ -734,8 +859,8 @@ const navCtx = $derived({
734
859
  background: transparent;
735
860
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
736
861
  cursor: pointer;
737
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
738
- padding: 5px 10px;
862
+ font: 600 12px / 1 var(--dt-sans, system-ui, sans-serif);
863
+ padding: 9px 12px;
739
864
  border-radius: 6px;
740
865
  letter-spacing: 0.04em;
741
866
  text-transform: uppercase;
@@ -761,19 +886,13 @@ const navCtx = $derived({
761
886
  min-width: 0;
762
887
  }
763
888
 
764
- .cal-m-today-bar {
765
- display: flex;
766
- justify-content: center;
767
- padding: 12px 8px 6px;
768
- flex-shrink: 0;
769
- }
770
-
771
889
  .cal-m-today {
772
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
890
+ font: 600 12px / 1 var(--dt-sans, system-ui, sans-serif);
773
891
  color: var(--dt-accent, #2563eb);
774
892
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 10%, transparent);
775
893
  border: none;
776
- padding: 5px 10px;
894
+ min-height: 40px;
895
+ padding: 5px 12px;
777
896
  border-radius: 6px;
778
897
  cursor: pointer;
779
898
  white-space: nowrap;
@@ -783,12 +902,16 @@ const navCtx = $derived({
783
902
  -webkit-tap-highlight-color: transparent;
784
903
  flex-shrink: 0;
785
904
  }
786
- .cal-m-today:hover {
905
+ .cal-m-today:hover:not(:disabled) {
787
906
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 18%, transparent);
788
907
  }
789
- .cal-m-today:active {
908
+ .cal-m-today:active:not(:disabled) {
790
909
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 25%, transparent);
791
910
  }
911
+ .cal-m-today:disabled {
912
+ opacity: 0.45;
913
+ cursor: default;
914
+ }
792
915
  .cal-m-today:focus-visible {
793
916
  outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
794
917
  outline-offset: 2px;
@@ -2,6 +2,7 @@ import { type Component, type Snippet } from 'svelte';
2
2
  import type { CalendarAdapter } from '../adapters/types.js';
3
3
  import type { CalendarViewId } from '../engine/view-state.svelte.js';
4
4
  import type { TimelineEvent, BlockedSlot } from '../core/types.js';
5
+ import { type CalendarLabels } from '../core/locale.js';
5
6
  import type { AutoThemeOptions } from '../theme/auto.js';
6
7
  /** One view registration */
7
8
  export interface CalendarView {
@@ -42,6 +43,12 @@ interface Props {
42
43
  dir?: 'ltr' | 'rtl' | 'auto';
43
44
  /** BCP 47 locale tag (e.g. 'en-US', 'ar-SA') — sets lang and locale for formatting */
44
45
  locale?: string;
46
+ /**
47
+ * Per-instance UI label overrides (merged over the global `setLabels()` set).
48
+ * Reactive: changing the prop re-renders all label text, and two calendars
49
+ * on one page can carry different languages.
50
+ */
51
+ labels?: Partial<CalendarLabels>;
45
52
  /** Read-only mode: disables drag, resize, empty-slot creation */
46
53
  readOnly?: boolean;
47
54
  /** Visible hour range: [startHour, endHour). Crops the grid to these hours. */
@@ -116,6 +123,13 @@ interface Props {
116
123
  ondayclick?: (date: Date) => void;
117
124
  /** Surfaced instead of silent console output when loading or mutations fail. */
118
125
  onerror?: (error: Error) => void;
126
+ /**
127
+ * Render the calendar in an IANA timezone (e.g. 'Europe/Warsaw').
128
+ * Events, "now" and day boundaries all shift; ranges passed to
129
+ * oneventcreate/oneventmove convert back to real instants.
130
+ * Default: the viewer's local time.
131
+ */
132
+ timezone?: string;
119
133
  }
120
134
  declare const Calendar: Component<Props, {}, "">;
121
135
  type Calendar = ReturnType<typeof Calendar>;
@@ -18,4 +18,4 @@ export interface Clock {
18
18
  * Must be called during component initialisation (before first await).
19
19
  * Automatically cleans up on unmount via onMount return.
20
20
  */
21
- export declare function createClock(): Clock;
21
+ export declare function createClock(timezone?: string): Clock;
@@ -13,19 +13,23 @@
13
13
  */
14
14
  import { onMount } from 'svelte';
15
15
  import { sod, fmtHM, fmtS, fractionalHour } from './time.js';
16
+ import { toZonedTime } from './timezone.js';
16
17
  /**
17
18
  * Create a shared reactive clock.
18
19
  *
19
20
  * Must be called during component initialisation (before first await).
20
21
  * Automatically cleans up on unmount via onMount return.
21
22
  */
22
- export function createClock() {
23
- let tick = $state(Date.now());
24
- let today = $state(sod(Date.now()));
23
+ export function createClock(timezone) {
24
+ // With a timezone, ticks are "zoned wall-clock" epoch ms — the same plane
25
+ // the wrapped adapter shifts event Dates into. Views stay zone-agnostic.
26
+ const now = () => (timezone ? toZonedTime(Date.now(), timezone).getTime() : Date.now());
27
+ let tick = $state(now());
28
+ let today = $state(sod(tick));
25
29
  let intervalId = null;
26
30
  function start() {
27
31
  intervalId = setInterval(() => {
28
- tick = Date.now();
32
+ tick = now();
29
33
  const sd = sod(tick);
30
34
  if (sd !== today)
31
35
  today = sd;
@@ -40,6 +40,14 @@ export interface CalendarLabels {
40
40
  nextMonth: string;
41
41
  calendar: string;
42
42
  viewMode: string;
43
+ /** Event status: cancelled */
44
+ cancelled: string;
45
+ /** Event status: tentative */
46
+ tentative: string;
47
+ /** Event status: fully booked */
48
+ full: string;
49
+ /** Event status: limited availability */
50
+ limited: string;
43
51
  dayNavigation: string;
44
52
  weekNavigation: string;
45
53
  dayPlanner: string;
@@ -64,6 +72,12 @@ export interface CalendarLabels {
64
72
  dayNOfTotal: (current: number, total: number) => string;
65
73
  /** e.g. "75% complete" */
66
74
  percentComplete: (pct: number) => string;
75
+ /** Relative countdown, e.g. "in 45m" */
76
+ inMinutes: (mins: number) => string;
77
+ /** Relative countdown, e.g. "in 2h 15m" / "in 2h" */
78
+ inHours: (hours: number, mins: number) => string;
79
+ /** Relative countdown, e.g. "in 3d" */
80
+ inDays: (days: number) => string;
67
81
  }
68
82
  /** English defaults — used unless overridden via `setLabels()`. */
69
83
  export declare const defaultLabels: CalendarLabels;
@@ -102,7 +116,7 @@ export declare function fmtDay(ms: number, todayMs: number, opts?: {
102
116
  /**
103
117
  * Format a week range label: "Feb 17 – 23, 2026" or "Jan 27 – Feb 2, 2026"
104
118
  */
105
- export declare function fmtWeekRange(weekStartMs: number, locale?: string): string;
119
+ export declare function fmtWeekRange(weekStartMs: number, locale?: string, weekEndMs?: number): string;
106
120
  /**
107
121
  * Format a Date as a compact time string.
108
122
  *
@@ -38,6 +38,10 @@ export const defaultLabels = {
38
38
  nextMonth: 'Next month',
39
39
  calendar: 'Calendar',
40
40
  viewMode: 'View mode',
41
+ cancelled: 'cancelled',
42
+ tentative: 'tentative',
43
+ full: 'full',
44
+ limited: 'limited',
41
45
  dayNavigation: 'Day navigation',
42
46
  weekNavigation: 'Week navigation',
43
47
  dayPlanner: 'Day planner',
@@ -57,6 +61,9 @@ export const defaultLabels = {
57
61
  showLess: 'Show less',
58
62
  dayNOfTotal: (current, total) => `day ${current} of ${total}`,
59
63
  percentComplete: (pct) => `${pct}% complete`,
64
+ inMinutes: (mins) => `in ${mins}m`,
65
+ inHours: (hours, mins) => (mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours}h`),
66
+ inDays: (days) => `in ${days}d`,
60
67
  };
61
68
  let _labels = { ...defaultLabels };
62
69
  /** Replace one or more UI labels. Merges with current labels. */
@@ -160,10 +167,10 @@ export function fmtDay(ms, todayMs, opts, locale) {
160
167
  /**
161
168
  * Format a week range label: "Feb 17 – 23, 2026" or "Jan 27 – Feb 2, 2026"
162
169
  */
163
- export function fmtWeekRange(weekStartMs, locale) {
170
+ export function fmtWeekRange(weekStartMs, locale, weekEndMs) {
164
171
  const loc = locale ?? defaultLocale;
165
172
  const s = new Date(weekStartMs);
166
- const e = new Date(weekStartMs + 6 * DAY_MS);
173
+ const e = new Date(weekEndMs ?? weekStartMs + 6 * DAY_MS);
167
174
  const sm = s.toLocaleDateString(loc, { month: 'short' });
168
175
  const em = e.toLocaleDateString(loc, { month: 'short' });
169
176
  const sy = s.getFullYear();
@@ -20,3 +20,15 @@ export declare function nowInZone(timezone: string): Date;
20
20
  * Returns a locale-aware string.
21
21
  */
22
22
  export declare function formatInTimeZone(date: Date | number, timezone: string, options?: Intl.DateTimeFormatOptions, locale?: string): string;
23
+ /**
24
+ * Wrap a CalendarAdapter so everything it emits is expressed as wall-clock
25
+ * Dates in `timezone`, and everything written through it is converted back to
26
+ * real instants. This is how the Calendar's `timezone` prop works: views keep
27
+ * doing plain local-time math on an already-shifted plane.
28
+ *
29
+ * Known limit shared by every wall-clock calendar UI: during a DST fall-back
30
+ * the repeated hour is ambiguous on the wall clock, so writes made inside it
31
+ * resolve to one of the two instants (date-fns-tz picks the offset).
32
+ */
33
+ import type { CalendarAdapter } from '../adapters/types.js';
34
+ export declare function wrapAdapterWithTimezone(adapter: CalendarAdapter, timezone: string): CalendarAdapter;