@nomideusz/svelte-calendar 0.9.0 → 0.11.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 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
+ }} />
668
+ ```
669
+
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' });
670
676
  ```
671
677
 
672
- Call `resetLabels()` to restore English defaults (`defaultLabels` exports them; `getLabels()` reads the active set).
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>
@@ -784,6 +790,8 @@ Drop into any HTML page — no build tools needed. Registers a `<day-calendar>`
784
790
  ></day-calendar>
785
791
  ```
786
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
+
787
795
  | Attribute | Description |
788
796
  |-----------|-------------|
789
797
  | `api` | REST endpoint — fetched as `GET {api}?start=...&end=...` |
@@ -23,7 +23,7 @@ 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";
@@ -52,6 +52,7 @@ let {
52
52
  borderRadius = 12,
53
53
  dir,
54
54
  locale,
55
+ labels: labelsProp,
55
56
  readOnly = false,
56
57
  visibleHours,
57
58
  initialDate,
@@ -95,11 +96,17 @@ function handleEventClick(ev) {
95
96
  selection.select(ev.id);
96
97
  oneventclick?.(ev);
97
98
  }
98
- let containerWidth = $state(0);
99
+ let containerWidth = $state(
100
+ typeof window !== "undefined" && window.matchMedia?.(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches ? window.innerWidth : 0
101
+ );
99
102
  const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
100
103
  const useMobile = $derived(
101
104
  mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp)
102
105
  );
106
+ const HEADER_STACK_BREAKPOINT = 520;
107
+ const stackHeader = $derived(
108
+ useMobile && containerWidth > 0 && containerWidth < HEADER_STACK_BREAKPOINT
109
+ );
103
110
  let calEl = $state();
104
111
  let probedTheme = $state("");
105
112
  const needsProbe = $derived(theme === auto && autoTheme !== false);
@@ -233,7 +240,7 @@ setContext("calendar", {
233
240
  return oneventhover;
234
241
  },
235
242
  get ondayclick() {
236
- return ondayclick;
243
+ return ondayclick ?? defaultDayClick;
237
244
  },
238
245
  get timezone() {
239
246
  return timezone;
@@ -287,6 +294,9 @@ setContext("calendar", {
287
294
  get compact() {
288
295
  return compact;
289
296
  },
297
+ get labels() {
298
+ return mergedLabels;
299
+ },
290
300
  // Load range (read/write)
291
301
  get loadRange() {
292
302
  return viewLoadRange;
@@ -347,6 +357,13 @@ const dateLabel = $derived.by(() => {
347
357
  day: "numeric"
348
358
  });
349
359
  }
360
+ if (viewState.mode === "week") {
361
+ return fmtWeekRange(
362
+ viewState.range.start.getTime(),
363
+ locale,
364
+ viewState.range.end.getTime() - 1
365
+ );
366
+ }
350
367
  return viewState.focusDate.toLocaleDateString(locale, {
351
368
  month: "long",
352
369
  year: "numeric"
@@ -356,19 +373,77 @@ const modes = $derived.by(() => {
356
373
  const g = new Set(desktopViews.map((v) => v.mode));
357
374
  return ["day", "week", "month"].filter((key) => g.has(key));
358
375
  });
359
- const L = $derived(getLabels());
376
+ const mergedLabels = $derived(
377
+ labelsProp ? { ...getLabels(), ...labelsProp } : getLabels()
378
+ );
379
+ const L = $derived(mergedLabels);
380
+ let lastViewLabel = $state(void 0);
381
+ $effect(() => {
382
+ const current = views.find((v) => v.id === viewState.view);
383
+ if (current && current.mode !== "month") lastViewLabel = current.label;
384
+ });
360
385
  function switchMode(g) {
361
- const currentLabel = desktopViews.find((v) => v.id === viewState.view)?.label ?? activeView?.label;
362
- const match = desktopViews.find((v) => v.mode === g && v.label === currentLabel);
386
+ const currentView = desktopViews.find((v) => v.id === viewState.view) ?? activeView;
387
+ const preferredLabel = currentView?.mode === "month" ? lastViewLabel ?? currentView?.label : currentView?.label;
388
+ const match = desktopViews.find((v) => v.mode === g && v.label === preferredLabel);
363
389
  const fallback = desktopViews.find((v) => v.mode === g);
364
390
  const target = match ?? fallback;
365
391
  if (target) viewState.setView(target.id);
366
392
  }
393
+ const labelsForMode = $derived.by(() => {
394
+ const seen = [];
395
+ for (const v of desktopViews) {
396
+ if (v.mode === viewState.mode && !seen.includes(v.label)) seen.push(v.label);
397
+ }
398
+ return seen;
399
+ });
400
+ function switchLabel(label) {
401
+ const target = desktopViews.find((v) => v.mode === viewState.mode && v.label === label);
402
+ if (target) viewState.setView(target.id);
403
+ }
404
+ const defaultDayClick = $derived.by(() => {
405
+ const target = desktopViews.find((v) => v.mode === "day" && v.label === lastViewLabel) ?? desktopViews.find((v) => v.mode === "day");
406
+ if (!target) return void 0;
407
+ return (date) => {
408
+ viewState.setFocusDate(date);
409
+ viewState.setView(target.id);
410
+ };
411
+ });
367
412
  const viewIncludesToday = $derived.by(() => {
368
- const now = Date.now();
413
+ const now = /* @__PURE__ */ new Date();
414
+ if (viewState.mode === "month") {
415
+ const f = viewState.focusDate;
416
+ return f.getMonth() === now.getMonth() && f.getFullYear() === now.getFullYear();
417
+ }
369
418
  const { start, end } = viewState.range;
370
- return now >= start.getTime() && now < end.getTime();
419
+ return now.getTime() >= start.getTime() && now.getTime() < end.getTime();
420
+ });
421
+ const resolvedDir = $derived.by(() => {
422
+ if (dir) return dir;
423
+ if (!locale) return void 0;
424
+ try {
425
+ const info = new Intl.Locale(locale);
426
+ const direction = info.textInfo?.direction ?? info.getTextInfo?.().direction;
427
+ return direction === "rtl" ? "rtl" : void 0;
428
+ } catch {
429
+ return void 0;
430
+ }
371
431
  });
432
+ function handleShortcuts(e) {
433
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
434
+ const t = e.target;
435
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
436
+ if (e.key === "t" || e.key === "T") {
437
+ e.preventDefault();
438
+ viewState.goToday();
439
+ } else if (e.key === "ArrowLeft") {
440
+ e.preventDefault();
441
+ viewState.prev();
442
+ } else if (e.key === "ArrowRight") {
443
+ e.preventDefault();
444
+ viewState.next();
445
+ }
446
+ }
372
447
  const headerCtx = $derived({
373
448
  dateLabel,
374
449
  mode: viewState.mode,
@@ -390,6 +465,7 @@ const navCtx = $derived({
390
465
  });
391
466
  </script>
392
467
 
468
+ <!-- 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 -->
393
469
  <div
394
470
  class="cal"
395
471
  bind:this={calEl}
@@ -397,8 +473,10 @@ const navCtx = $derived({
397
473
  class:cal--auto={heightProp === 'auto'}
398
474
  role="region"
399
475
  aria-label={L.calendar}
400
- dir={dir}
476
+ aria-busy={store.loading || undefined}
477
+ dir={resolvedDir}
401
478
  lang={locale}
479
+ onkeydown={handleShortcuts}
402
480
  >
403
481
  <!-- ─── Custom header snippet (replaces all chrome) ─── -->
404
482
  {#if headerSnippet}
@@ -406,23 +484,18 @@ const navCtx = $derived({
406
484
 
407
485
  <!-- ─── Mobile header (flow layout, no absolute) ─── -->
408
486
  {:else if useMobile && (showNavigation || (showModePills && modes.length > 1) || dateLabel)}
409
- <div class="cal-m-hd">
487
+ {@const titleBelow = stackHeader && !!dateLabel}
488
+ <div class="cal-m-hd" class:cal-m-hd--stack={stackHeader} class:cal-m-hd--titled={titleBelow}>
410
489
  <div class="cal-m-left">
411
- {#if navigationSnippet}
412
- {@render navigationSnippet(navCtx)}
413
- {:else if showNavigation}
414
- <button class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
415
- <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>
416
- </button>
417
- {/if}
418
-
419
490
  {#if showModePills && modes.length > 1}
420
- <div class="cal-m-pills" role="group" aria-label={L.viewMode}>
421
- {#each modes as g}
491
+ <div class="cal-m-pills" role="radiogroup" aria-label={L.viewMode}>
492
+ {#each modes as g (g)}
422
493
  <button
494
+ type="button"
423
495
  class="cal-m-pill"
424
496
  class:cal-m-pill--active={viewState.mode === g}
425
- aria-pressed={viewState.mode === g}
497
+ role="radio"
498
+ aria-checked={viewState.mode === g}
426
499
  onclick={() => switchMode(g)}
427
500
  >
428
501
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -432,23 +505,36 @@ const navCtx = $derived({
432
505
  {/if}
433
506
  </div>
434
507
 
435
- <span class="cal-m-title">{dateLabel}</span>
508
+ {#if !titleBelow}
509
+ <span class="cal-m-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
510
+ {/if}
436
511
 
437
512
  <div class="cal-m-right">
438
- {#if !navigationSnippet && showNavigation}
439
- <button class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
513
+ {#if navigationSnippet}
514
+ {@render navigationSnippet(navCtx)}
515
+ {:else if showNavigation}
516
+ <!-- Always rendered (disabled on today) so navigating never shifts layout -->
517
+ <button
518
+ type="button"
519
+ class="cal-m-today"
520
+ onclick={() => viewState.goToday()}
521
+ disabled={viewIncludesToday}
522
+ title={L.goToToday}
523
+ >
524
+ {L.today}
525
+ </button>
526
+ <button type="button" class="cal-m-nav" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
527
+ <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>
528
+ </button>
529
+ <button type="button" class="cal-m-nav" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
440
530
  <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>
441
531
  </button>
442
532
  {/if}
443
533
  </div>
444
534
  </div>
445
-
446
- <!-- Today pill — floats below header, doesn't affect header flow -->
447
- {#if !navigationSnippet && showNavigation && !viewIncludesToday}
448
- <div class="cal-m-today-bar">
449
- <button class="cal-m-today" onclick={() => viewState.goToday()}>
450
- {L.today}
451
- </button>
535
+ {#if titleBelow}
536
+ <div class="cal-m-titlebar">
537
+ <span class="cal-m-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
452
538
  </div>
453
539
  {/if}
454
540
 
@@ -459,28 +545,52 @@ const navCtx = $derived({
459
545
  {#if navigationSnippet}
460
546
  {@render navigationSnippet(navCtx)}
461
547
  {:else if showNavigation}
462
- {#if !viewIncludesToday}
463
- <button class="cal-hd-today" onclick={() => viewState.goToday()} title={L.goToToday}>
464
- {L.today}
465
- </button>
466
- {/if}
467
- <button class="cal-hd-btn" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
548
+ <!-- Always rendered (disabled on today) so navigating never shifts the centered title -->
549
+ <button
550
+ type="button"
551
+ class="cal-hd-today"
552
+ onclick={() => viewState.goToday()}
553
+ disabled={viewIncludesToday}
554
+ title={L.goToToday}
555
+ >
556
+ {L.today}
557
+ </button>
558
+ <button type="button" class="cal-hd-btn" onclick={() => viewState.prev()} aria-label={viewState.mode === 'day' ? L.previousDay : viewState.mode === 'month' ? L.previousMonth : L.previousWeek}>
468
559
  <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>
469
560
  </button>
470
- <button class="cal-hd-btn" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
561
+ <button type="button" class="cal-hd-btn" onclick={() => viewState.next()} aria-label={viewState.mode === 'day' ? L.nextDay : viewState.mode === 'month' ? L.nextMonth : L.nextWeek}>
471
562
  <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>
472
563
  </button>
473
564
  {/if}
474
565
  </div>
475
- <span class="cal-hd-title">{dateLabel}</span>
566
+ <span class="cal-hd-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
476
567
  <div class="cal-hd-side cal-hd-side--end">
568
+ {#if showModePills && labelsForMode.length > 1}
569
+ <!-- View-type switcher (e.g. Planner ↔ Agenda) for the current mode -->
570
+ <div class="cal-pills cal-pills--labels" role="radiogroup" aria-label={L.viewMode}>
571
+ {#each labelsForMode as label (label)}
572
+ <button
573
+ type="button"
574
+ class="cal-pill"
575
+ class:cal-pill--active={activeView?.label === label}
576
+ role="radio"
577
+ aria-checked={activeView?.label === label}
578
+ onclick={() => switchLabel(label)}
579
+ >
580
+ {label}
581
+ </button>
582
+ {/each}
583
+ </div>
584
+ {/if}
477
585
  {#if showModePills && modes.length > 1}
478
- <div class="cal-pills" role="group" aria-label={L.viewMode}>
479
- {#each modes as g}
586
+ <div class="cal-pills" role="radiogroup" aria-label={L.viewMode}>
587
+ {#each modes as g (g)}
480
588
  <button
589
+ type="button"
481
590
  class="cal-pill"
482
591
  class:cal-pill--active={viewState.mode === g}
483
- aria-pressed={viewState.mode === g}
592
+ role="radio"
593
+ aria-checked={viewState.mode === g}
484
594
  onclick={() => switchMode(g)}
485
595
  >
486
596
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -543,6 +653,7 @@ const navCtx = $derived({
543
653
  /* ── Desktop header ── */
544
654
  .cal-hd {
545
655
  display: flex;
656
+ flex-wrap: wrap;
546
657
  align-items: center;
547
658
  gap: 8px;
548
659
  padding: 8px 12px;
@@ -611,10 +722,14 @@ const navCtx = $derived({
611
722
  transition: background 120ms, color 120ms, border-color 120ms;
612
723
  }
613
724
 
614
- .cal-hd-today:hover {
725
+ .cal-hd-today:hover:not(:disabled) {
615
726
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
616
727
  border-color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
617
728
  }
729
+ .cal-hd-today:disabled {
730
+ opacity: 0.45;
731
+ cursor: default;
732
+ }
618
733
 
619
734
  .cal-pills {
620
735
  display: flex;
@@ -637,7 +752,9 @@ const navCtx = $derived({
637
752
  transition: background 100ms, color 100ms;
638
753
  }
639
754
 
640
- .cal-pill:hover {
755
+ /* :not(--active) — the hover rule otherwise outranks the active color,
756
+ and iOS keeps :hover stuck after a tap (dark text on the accent). */
757
+ .cal-pill:hover:not(.cal-pill--active) {
641
758
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
642
759
  }
643
760
 
@@ -685,6 +802,13 @@ const navCtx = $derived({
685
802
  100% { transform: translateX(100%); }
686
803
  }
687
804
 
805
+ @media (prefers-reduced-motion: reduce) {
806
+ .cal-loading {
807
+ animation: none;
808
+ background: var(--dt-accent-dim, rgba(37, 99, 235, 0.12));
809
+ }
810
+ }
811
+
688
812
  /* ── Mobile header (flow layout) ── */
689
813
  .cal-m-hd {
690
814
  display: flex;
@@ -696,6 +820,26 @@ const navCtx = $derived({
696
820
  min-height: 44px;
697
821
  }
698
822
 
823
+ /* Narrow containers: the date label moves to its own row (.cal-m-titlebar),
824
+ so the controls row spreads pills and nav to the edges. */
825
+ .cal-m-hd--stack {
826
+ justify-content: space-between;
827
+ }
828
+ .cal-m-hd--titled {
829
+ border-bottom: none;
830
+ padding-bottom: 2px;
831
+ }
832
+ .cal-m-titlebar {
833
+ display: flex;
834
+ justify-content: center;
835
+ padding: 0 8px 8px;
836
+ border-bottom: 1px solid var(--dt-border, rgba(0, 0, 0, 0.08));
837
+ flex-shrink: 0;
838
+ }
839
+ .cal-m-titlebar .cal-m-title {
840
+ flex: 0 1 auto;
841
+ }
842
+
699
843
  .cal-m-left,
700
844
  .cal-m-right {
701
845
  display: flex;
@@ -712,8 +856,8 @@ const navCtx = $derived({
712
856
  display: flex;
713
857
  align-items: center;
714
858
  justify-content: center;
715
- width: 32px;
716
- height: 32px;
859
+ width: 40px;
860
+ height: 40px;
717
861
  border: none;
718
862
  background: transparent;
719
863
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
@@ -749,15 +893,15 @@ const navCtx = $derived({
749
893
  background: transparent;
750
894
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
751
895
  cursor: pointer;
752
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
753
- padding: 5px 10px;
896
+ font: 600 12px / 1 var(--dt-sans, system-ui, sans-serif);
897
+ padding: 9px 12px;
754
898
  border-radius: 6px;
755
899
  letter-spacing: 0.04em;
756
900
  text-transform: uppercase;
757
901
  transition: background 100ms, color 100ms;
758
902
  -webkit-tap-highlight-color: transparent;
759
903
  }
760
- .cal-m-pill:hover {
904
+ .cal-m-pill:hover:not(.cal-m-pill--active) {
761
905
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
762
906
  }
763
907
  .cal-m-pill--active {
@@ -776,19 +920,13 @@ const navCtx = $derived({
776
920
  min-width: 0;
777
921
  }
778
922
 
779
- .cal-m-today-bar {
780
- display: flex;
781
- justify-content: center;
782
- padding: 12px 8px 6px;
783
- flex-shrink: 0;
784
- }
785
-
786
923
  .cal-m-today {
787
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
924
+ font: 600 12px / 1 var(--dt-sans, system-ui, sans-serif);
788
925
  color: var(--dt-accent, #2563eb);
789
926
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 10%, transparent);
790
927
  border: none;
791
- padding: 5px 10px;
928
+ min-height: 40px;
929
+ padding: 5px 12px;
792
930
  border-radius: 6px;
793
931
  cursor: pointer;
794
932
  white-space: nowrap;
@@ -798,12 +936,16 @@ const navCtx = $derived({
798
936
  -webkit-tap-highlight-color: transparent;
799
937
  flex-shrink: 0;
800
938
  }
801
- .cal-m-today:hover {
939
+ .cal-m-today:hover:not(:disabled) {
802
940
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 18%, transparent);
803
941
  }
804
- .cal-m-today:active {
942
+ .cal-m-today:active:not(:disabled) {
805
943
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 25%, transparent);
806
944
  }
945
+ .cal-m-today:disabled {
946
+ opacity: 0.45;
947
+ cursor: default;
948
+ }
807
949
  .cal-m-today:focus-visible {
808
950
  outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
809
951
  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. */
@@ -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();
@@ -99,6 +99,23 @@ function mix(c1, c2, t) {
99
99
  Math.round(c1[2] + (c2[2] - c1[2]) * t),
100
100
  ];
101
101
  }
102
+ // ── Shadow-aware DOM walking ────────────────────────────
103
+ /**
104
+ * Parent element that hops shadow boundaries. When a node is a direct child
105
+ * of a ShadowRoot, `parentElement` is null — continue the walk from the
106
+ * shadow host so probes can still see the host page (the embeddable widget
107
+ * mounts the calendar inside a shadow root).
108
+ */
109
+ function parentAcrossShadow(node) {
110
+ if (node.parentElement)
111
+ return node.parentElement;
112
+ const root = node.getRootNode();
113
+ return typeof ShadowRoot !== 'undefined' &&
114
+ root instanceof ShadowRoot &&
115
+ root.host instanceof HTMLElement
116
+ ? root.host
117
+ : null;
118
+ }
102
119
  // ── Text color detection ────────────────────────────────
103
120
  /**
104
121
  * Common CSS variable names for text / foreground color used by popular frameworks.
@@ -164,7 +181,7 @@ function probeTextColor(el, bg) {
164
181
  break;
165
182
  }
166
183
  }
167
- node = node.parentElement;
184
+ node = parentAcrossShadow(node);
168
185
  }
169
186
  // 3. Walk up the DOM reading *computed* color
170
187
  node = el;
@@ -178,7 +195,7 @@ function probeTextColor(el, bg) {
178
195
  }
179
196
  }
180
197
  catch { /* ignore */ }
181
- node = node.parentElement;
198
+ node = parentAcrossShadow(node);
182
199
  }
183
200
  // Pick the first candidate with adequate contrast against the background.
184
201
  // WCAG AA large-text minimum is 3:1.
@@ -367,7 +384,7 @@ function probeBackground(el) {
367
384
  if (rgb)
368
385
  return result(rgb);
369
386
  }
370
- node = node.parentElement;
387
+ node = parentAcrossShadow(node);
371
388
  }
372
389
  // 3. Fall back to computed backgroundColor (may be mid-transition, but
373
390
  // still correct when no transition is active).
@@ -382,7 +399,7 @@ function probeBackground(el) {
382
399
  catch {
383
400
  // getComputedStyle may fail in test environments
384
401
  }
385
- node = node.parentElement;
402
+ node = parentAcrossShadow(node);
386
403
  }
387
404
  // 4. Ultimate fallback: check color-scheme preference
388
405
  if (typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
@@ -400,7 +417,9 @@ function probeBackground(el) {
400
417
  export function probeHostTheme(el, options = {}) {
401
418
  // Start probing from the parent — `el` itself is the calendar root, which
402
419
  // has its own --dt-bg fallback in CSS. We need the *host page's* context.
403
- const host = el.parentElement ?? el;
420
+ // (Shadow-aware: inside a shadow root the parent hop lands on the shadow
421
+ // host, i.e. the <day-calendar> element in the light DOM.)
422
+ const host = parentAcrossShadow(el) ?? el;
404
423
  const htmlRoot = (host.closest('body') ?? host) instanceof HTMLElement
405
424
  ? (host.closest('body') ?? host)
406
425
  : document.body;
@@ -445,7 +464,7 @@ export function probeHostTheme(el, options = {}) {
445
464
  // ── Accent derivatives ──
446
465
  const accentDim = isDark ? 0.15 : 0.12;
447
466
  const glow = isDark ? 0.30 : 0.25;
448
- const todayBg = isDark ? 0.03 : 0.04;
467
+ const todayBg = isDark ? 0.07 : 0.07;
449
468
  // Ensure accent is readable on the background — adjust lightness if needed
450
469
  const accentL = isDark
451
470
  ? Math.max(aL, 0.45) // bright enough on dark
@@ -475,6 +494,8 @@ export function probeHostTheme(el, options = {}) {
475
494
  `--dt-btn-text: ${btnText}`,
476
495
  `--dt-scrollbar: ${rgba(...borderRgb, scrollAlpha)}`,
477
496
  `--dt-success: ${rgba(...successRgb, 0.7)}`,
497
+ `--dt-weekend-bg: ${rgba(...borderRgb, isDark ? 0.03 : 0.02)}`,
498
+ `--dt-hover: ${rgba(...borderRgb, isDark ? 0.06 : 0.04)}`,
478
499
  `--dt-sans: ${fonts.sans}`,
479
500
  `--dt-mono: ${fonts.mono}`,
480
501
  ];