@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.
- package/README.md +29 -8
- package/dist/calendar/Calendar.svelte +187 -64
- package/dist/calendar/Calendar.svelte.d.ts +14 -0
- package/dist/core/clock.svelte.d.ts +1 -1
- package/dist/core/clock.svelte.js +8 -4
- package/dist/core/locale.d.ts +15 -1
- package/dist/core/locale.js +9 -2
- package/dist/core/timezone.d.ts +12 -0
- package/dist/core/timezone.js +31 -0
- package/dist/headless/create-calendar.svelte.js +12 -6
- package/dist/headless/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/theme/auto.js +27 -6
- package/dist/theme/presets.d.ts +4 -4
- package/dist/theme/presets.js +11 -7
- package/dist/views/agenda/AgendaDay.svelte +260 -111
- package/dist/views/agenda/AgendaWeek.svelte +191 -66
- package/dist/views/mobile/MobileDay.svelte +409 -214
- package/dist/views/mobile/MobileWeek.svelte +137 -75
- package/dist/views/mobile/swipe.d.ts +28 -0
- package/dist/views/mobile/swipe.js +64 -0
- package/dist/views/month/MonthGrid.svelte +167 -31
- package/dist/views/planner/PlannerDay.svelte +156 -81
- package/dist/views/planner/PlannerWeek.svelte +1156 -631
- package/dist/views/planner/PlannerWeek.svelte.d.ts +2 -0
- package/dist/views/shared/context.svelte.d.ts +4 -0
- package/dist/views/shared/context.svelte.js +14 -9
- package/dist/views/shared/format.d.ts +2 -1
- package/dist/views/shared/format.js +8 -5
- package/dist/widget/CalendarWidget.svelte +33 -5
- package/dist/widget/CalendarWidget.svelte.d.ts +16 -2
- package/dist/widget/widget.d.ts +9 -0
- package/dist/widget/widget.js +59 -13
- package/package.json +1 -1
- package/widget/widget.js +3830 -2755
- 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
|
|
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
|
-
```
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
const
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
347
|
-
const
|
|
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
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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="
|
|
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
|
-
|
|
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:
|
|
701
|
-
height:
|
|
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
|
|
738
|
-
padding:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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 =
|
|
32
|
+
tick = now();
|
|
29
33
|
const sd = sod(tick);
|
|
30
34
|
if (sd !== today)
|
|
31
35
|
today = sd;
|
package/dist/core/locale.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/core/locale.js
CHANGED
|
@@ -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();
|
package/dist/core/timezone.d.ts
CHANGED
|
@@ -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;
|