@nomideusz/svelte-calendar 0.9.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 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,7 +96,9 @@ 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 ? MOBILE_BREAKPOINT - 1 : 0
101
+ );
99
102
  const isMobileContainer = $derived(containerWidth > 0 && containerWidth < MOBILE_BREAKPOINT);
100
103
  const useMobile = $derived(
101
104
  mobileProp === "auto" ? isMobileContainer : Boolean(mobileProp)
@@ -233,7 +236,7 @@ setContext("calendar", {
233
236
  return oneventhover;
234
237
  },
235
238
  get ondayclick() {
236
- return ondayclick;
239
+ return ondayclick ?? defaultDayClick;
237
240
  },
238
241
  get timezone() {
239
242
  return timezone;
@@ -287,6 +290,9 @@ setContext("calendar", {
287
290
  get compact() {
288
291
  return compact;
289
292
  },
293
+ get labels() {
294
+ return mergedLabels;
295
+ },
290
296
  // Load range (read/write)
291
297
  get loadRange() {
292
298
  return viewLoadRange;
@@ -347,6 +353,13 @@ const dateLabel = $derived.by(() => {
347
353
  day: "numeric"
348
354
  });
349
355
  }
356
+ if (viewState.mode === "week") {
357
+ return fmtWeekRange(
358
+ viewState.range.start.getTime(),
359
+ locale,
360
+ viewState.range.end.getTime() - 1
361
+ );
362
+ }
350
363
  return viewState.focusDate.toLocaleDateString(locale, {
351
364
  month: "long",
352
365
  year: "numeric"
@@ -356,19 +369,77 @@ const modes = $derived.by(() => {
356
369
  const g = new Set(desktopViews.map((v) => v.mode));
357
370
  return ["day", "week", "month"].filter((key) => g.has(key));
358
371
  });
359
- 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
+ });
360
381
  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);
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);
363
385
  const fallback = desktopViews.find((v) => v.mode === g);
364
386
  const target = match ?? fallback;
365
387
  if (target) viewState.setView(target.id);
366
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
+ });
367
408
  const viewIncludesToday = $derived.by(() => {
368
- 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
+ }
369
414
  const { start, end } = viewState.range;
370
- return now >= start.getTime() && now < end.getTime();
415
+ return now.getTime() >= start.getTime() && now.getTime() < end.getTime();
371
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
+ }
372
443
  const headerCtx = $derived({
373
444
  dateLabel,
374
445
  mode: viewState.mode,
@@ -390,6 +461,7 @@ const navCtx = $derived({
390
461
  });
391
462
  </script>
392
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 -->
393
465
  <div
394
466
  class="cal"
395
467
  bind:this={calEl}
@@ -397,8 +469,10 @@ const navCtx = $derived({
397
469
  class:cal--auto={heightProp === 'auto'}
398
470
  role="region"
399
471
  aria-label={L.calendar}
400
- dir={dir}
472
+ aria-busy={store.loading || undefined}
473
+ dir={resolvedDir}
401
474
  lang={locale}
475
+ onkeydown={handleShortcuts}
402
476
  >
403
477
  <!-- ─── Custom header snippet (replaces all chrome) ─── -->
404
478
  {#if headerSnippet}
@@ -408,21 +482,15 @@ const navCtx = $derived({
408
482
  {:else if useMobile && (showNavigation || (showModePills && modes.length > 1) || dateLabel)}
409
483
  <div class="cal-m-hd">
410
484
  <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
485
  {#if showModePills && modes.length > 1}
420
- <div class="cal-m-pills" role="group" aria-label={L.viewMode}>
421
- {#each modes as g}
486
+ <div class="cal-m-pills" role="radiogroup" aria-label={L.viewMode}>
487
+ {#each modes as g (g)}
422
488
  <button
489
+ type="button"
423
490
  class="cal-m-pill"
424
491
  class:cal-m-pill--active={viewState.mode === g}
425
- aria-pressed={viewState.mode === g}
492
+ role="radio"
493
+ aria-checked={viewState.mode === g}
426
494
  onclick={() => switchMode(g)}
427
495
  >
428
496
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -432,26 +500,32 @@ const navCtx = $derived({
432
500
  {/if}
433
501
  </div>
434
502
 
435
- <span class="cal-m-title">{dateLabel}</span>
503
+ <span class="cal-m-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
436
504
 
437
505
  <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}>
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}>
440
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>
441
524
  </button>
442
525
  {/if}
443
526
  </div>
444
527
  </div>
445
528
 
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>
452
- </div>
453
- {/if}
454
-
455
529
  <!-- ─── Desktop header ─── -->
456
530
  {:else if showNavigation || (showModePills && modes.length > 1) || dateLabel}
457
531
  <div class="cal-hd">
@@ -459,28 +533,52 @@ const navCtx = $derived({
459
533
  {#if navigationSnippet}
460
534
  {@render navigationSnippet(navCtx)}
461
535
  {: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}>
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}>
468
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>
469
548
  </button>
470
- <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}>
471
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>
472
551
  </button>
473
552
  {/if}
474
553
  </div>
475
- <span class="cal-hd-title">{dateLabel}</span>
554
+ <span class="cal-hd-title" role="status" aria-live="polite" aria-atomic="true">{dateLabel}</span>
476
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}
477
573
  {#if showModePills && modes.length > 1}
478
- <div class="cal-pills" role="group" aria-label={L.viewMode}>
479
- {#each modes as g}
574
+ <div class="cal-pills" role="radiogroup" aria-label={L.viewMode}>
575
+ {#each modes as g (g)}
480
576
  <button
577
+ type="button"
481
578
  class="cal-pill"
482
579
  class:cal-pill--active={viewState.mode === g}
483
- aria-pressed={viewState.mode === g}
580
+ role="radio"
581
+ aria-checked={viewState.mode === g}
484
582
  onclick={() => switchMode(g)}
485
583
  >
486
584
  {g === 'day' ? L.day : g === 'week' ? L.week : L.month}
@@ -543,6 +641,7 @@ const navCtx = $derived({
543
641
  /* ── Desktop header ── */
544
642
  .cal-hd {
545
643
  display: flex;
644
+ flex-wrap: wrap;
546
645
  align-items: center;
547
646
  gap: 8px;
548
647
  padding: 8px 12px;
@@ -611,10 +710,14 @@ const navCtx = $derived({
611
710
  transition: background 120ms, color 120ms, border-color 120ms;
612
711
  }
613
712
 
614
- .cal-hd-today:hover {
713
+ .cal-hd-today:hover:not(:disabled) {
615
714
  color: var(--dt-text, rgba(0, 0, 0, 0.87));
616
715
  border-color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
617
716
  }
717
+ .cal-hd-today:disabled {
718
+ opacity: 0.45;
719
+ cursor: default;
720
+ }
618
721
 
619
722
  .cal-pills {
620
723
  display: flex;
@@ -685,6 +788,13 @@ const navCtx = $derived({
685
788
  100% { transform: translateX(100%); }
686
789
  }
687
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
+
688
798
  /* ── Mobile header (flow layout) ── */
689
799
  .cal-m-hd {
690
800
  display: flex;
@@ -712,8 +822,8 @@ const navCtx = $derived({
712
822
  display: flex;
713
823
  align-items: center;
714
824
  justify-content: center;
715
- width: 32px;
716
- height: 32px;
825
+ width: 40px;
826
+ height: 40px;
717
827
  border: none;
718
828
  background: transparent;
719
829
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
@@ -749,8 +859,8 @@ const navCtx = $derived({
749
859
  background: transparent;
750
860
  color: var(--dt-text-2, rgba(0, 0, 0, 0.54));
751
861
  cursor: pointer;
752
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
753
- padding: 5px 10px;
862
+ font: 600 12px / 1 var(--dt-sans, system-ui, sans-serif);
863
+ padding: 9px 12px;
754
864
  border-radius: 6px;
755
865
  letter-spacing: 0.04em;
756
866
  text-transform: uppercase;
@@ -776,19 +886,13 @@ const navCtx = $derived({
776
886
  min-width: 0;
777
887
  }
778
888
 
779
- .cal-m-today-bar {
780
- display: flex;
781
- justify-content: center;
782
- padding: 12px 8px 6px;
783
- flex-shrink: 0;
784
- }
785
-
786
889
  .cal-m-today {
787
- font: 600 11px / 1 var(--dt-sans, system-ui, sans-serif);
890
+ font: 600 12px / 1 var(--dt-sans, system-ui, sans-serif);
788
891
  color: var(--dt-accent, #2563eb);
789
892
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 10%, transparent);
790
893
  border: none;
791
- padding: 5px 10px;
894
+ min-height: 40px;
895
+ padding: 5px 12px;
792
896
  border-radius: 6px;
793
897
  cursor: pointer;
794
898
  white-space: nowrap;
@@ -798,12 +902,16 @@ const navCtx = $derived({
798
902
  -webkit-tap-highlight-color: transparent;
799
903
  flex-shrink: 0;
800
904
  }
801
- .cal-m-today:hover {
905
+ .cal-m-today:hover:not(:disabled) {
802
906
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 18%, transparent);
803
907
  }
804
- .cal-m-today:active {
908
+ .cal-m-today:active:not(:disabled) {
805
909
  background: color-mix(in srgb, var(--dt-accent, #2563eb) 25%, transparent);
806
910
  }
911
+ .cal-m-today:disabled {
912
+ opacity: 0.45;
913
+ cursor: default;
914
+ }
807
915
  .cal-m-today:focus-visible {
808
916
  outline: 2px solid color-mix(in srgb, var(--dt-accent, #2563eb) 55%, transparent);
809
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. */
@@ -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
  ];
@@ -26,13 +26,13 @@ export declare const auto = "";
26
26
  * Neutral — explicit light theme. White bg, blue accent, inherits host fonts.
27
27
  * Use when embedding standalone without ancestor --dt-* vars.
28
28
  */
29
- export declare const neutral = "\n\t--dt-stage-bg: #ffffff;\n\t--dt-bg: #ffffff;\n\t--dt-surface: #f9fafb;\n\t--dt-border: rgba(0, 0, 0, 0.08);\n\t--dt-border-day: rgba(0, 0, 0, 0.14);\n\t--dt-text: rgba(0, 0, 0, 0.87);\n\t--dt-text-2: rgba(0, 0, 0, 0.54);\n\t--dt-text-3: rgba(0, 0, 0, 0.38);\n\t--dt-accent: #2563eb;\n\t--dt-accent-dim: rgba(37, 99, 235, 0.12);\n\t--dt-glow: rgba(37, 99, 235, 0.25);\n\t--dt-today-bg: rgba(37, 99, 235, 0.04);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(0, 0, 0, 0.1);\n\t--dt-success: rgba(22, 163, 74, 0.7);\n\t--dt-serif: inherit;\n\t--dt-sans: inherit;\n\t--dt-mono: ui-monospace, 'SFMono-Regular', monospace;\n";
29
+ export declare const neutral = "\n\t--dt-stage-bg: #ffffff;\n\t--dt-bg: #ffffff;\n\t--dt-surface: #f9fafb;\n\t--dt-border: rgba(0, 0, 0, 0.08);\n\t--dt-border-day: rgba(0, 0, 0, 0.14);\n\t--dt-text: rgba(0, 0, 0, 0.87);\n\t--dt-text-2: rgba(0, 0, 0, 0.54);\n\t--dt-text-3: rgba(0, 0, 0, 0.38);\n\t--dt-accent: var(--asini-accent, #2563eb);\n\t--dt-accent-dim: color-mix(in srgb, var(--dt-accent) 12%, transparent);\n\t--dt-glow: color-mix(in srgb, var(--dt-accent) 25%, transparent);\n\t--dt-today-bg: color-mix(in srgb, var(--dt-accent) 7%, transparent);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(0, 0, 0, 0.1);\n\t--dt-success: rgba(22, 163, 74, 0.7);\n\t--dt-weekend-bg: rgba(0, 0, 0, 0.02);\n\t--dt-hover: rgba(0, 0, 0, 0.04);\n\t--dt-sans: inherit;\n\t--dt-mono: ui-monospace, 'SFMono-Regular', monospace;\n";
30
30
  /** Midnight Industrial — dark charcoal + red accent, tech monitoring */
31
- export declare const midnight = "\n\t--dt-stage-bg: #080a0f;\n\t--dt-bg: #0b0e14;\n\t--dt-surface: #10141c;\n\t--dt-border: rgba(148, 163, 184, 0.07);\n\t--dt-border-day: rgba(148, 163, 184, 0.14);\n\t--dt-text: rgba(226, 232, 240, 0.85);\n\t--dt-text-2: rgba(148, 163, 184, 0.55);\n\t--dt-text-3: rgba(100, 116, 139, 0.55);\n\t--dt-accent: #ef4444;\n\t--dt-accent-dim: rgba(239, 68, 68, 0.18);\n\t--dt-glow: rgba(239, 68, 68, 0.35);\n\t--dt-today-bg: rgba(239, 68, 68, 0.02);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(148, 163, 184, 0.12);\n\t--dt-success: rgba(74, 222, 128, 0.7);\n\t--dt-serif: Georgia, 'Times New Roman', serif;\n";
31
+ export declare const midnight = "\n\t--dt-stage-bg: #080a0f;\n\t--dt-bg: #0b0e14;\n\t--dt-surface: #10141c;\n\t--dt-border: rgba(148, 163, 184, 0.07);\n\t--dt-border-day: rgba(148, 163, 184, 0.14);\n\t--dt-text: rgba(226, 232, 240, 0.85);\n\t--dt-text-2: rgba(148, 163, 184, 0.55);\n\t--dt-text-3: rgba(100, 116, 139, 0.55);\n\t--dt-accent: #ef4444;\n\t--dt-accent-dim: rgba(239, 68, 68, 0.18);\n\t--dt-glow: rgba(239, 68, 68, 0.35);\n\t--dt-today-bg: rgba(239, 68, 68, 0.07);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(148, 163, 184, 0.12);\n\t--dt-success: rgba(74, 222, 128, 0.7);\n\t--dt-weekend-bg: rgba(148, 163, 184, 0.03);\n\t--dt-hover: rgba(148, 163, 184, 0.06);\n\t--dt-sans: inherit;\n\t--dt-mono: ui-monospace, 'SFMono-Regular', monospace;\n";
32
32
  /** All available presets keyed by name */
33
33
  export declare const presets: {
34
34
  readonly auto: "";
35
- readonly neutral: "\n\t--dt-stage-bg: #ffffff;\n\t--dt-bg: #ffffff;\n\t--dt-surface: #f9fafb;\n\t--dt-border: rgba(0, 0, 0, 0.08);\n\t--dt-border-day: rgba(0, 0, 0, 0.14);\n\t--dt-text: rgba(0, 0, 0, 0.87);\n\t--dt-text-2: rgba(0, 0, 0, 0.54);\n\t--dt-text-3: rgba(0, 0, 0, 0.38);\n\t--dt-accent: #2563eb;\n\t--dt-accent-dim: rgba(37, 99, 235, 0.12);\n\t--dt-glow: rgba(37, 99, 235, 0.25);\n\t--dt-today-bg: rgba(37, 99, 235, 0.04);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(0, 0, 0, 0.1);\n\t--dt-success: rgba(22, 163, 74, 0.7);\n\t--dt-serif: inherit;\n\t--dt-sans: inherit;\n\t--dt-mono: ui-monospace, 'SFMono-Regular', monospace;\n";
36
- readonly midnight: "\n\t--dt-stage-bg: #080a0f;\n\t--dt-bg: #0b0e14;\n\t--dt-surface: #10141c;\n\t--dt-border: rgba(148, 163, 184, 0.07);\n\t--dt-border-day: rgba(148, 163, 184, 0.14);\n\t--dt-text: rgba(226, 232, 240, 0.85);\n\t--dt-text-2: rgba(148, 163, 184, 0.55);\n\t--dt-text-3: rgba(100, 116, 139, 0.55);\n\t--dt-accent: #ef4444;\n\t--dt-accent-dim: rgba(239, 68, 68, 0.18);\n\t--dt-glow: rgba(239, 68, 68, 0.35);\n\t--dt-today-bg: rgba(239, 68, 68, 0.02);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(148, 163, 184, 0.12);\n\t--dt-success: rgba(74, 222, 128, 0.7);\n\t--dt-serif: Georgia, 'Times New Roman', serif;\n";
35
+ readonly neutral: "\n\t--dt-stage-bg: #ffffff;\n\t--dt-bg: #ffffff;\n\t--dt-surface: #f9fafb;\n\t--dt-border: rgba(0, 0, 0, 0.08);\n\t--dt-border-day: rgba(0, 0, 0, 0.14);\n\t--dt-text: rgba(0, 0, 0, 0.87);\n\t--dt-text-2: rgba(0, 0, 0, 0.54);\n\t--dt-text-3: rgba(0, 0, 0, 0.38);\n\t--dt-accent: var(--asini-accent, #2563eb);\n\t--dt-accent-dim: color-mix(in srgb, var(--dt-accent) 12%, transparent);\n\t--dt-glow: color-mix(in srgb, var(--dt-accent) 25%, transparent);\n\t--dt-today-bg: color-mix(in srgb, var(--dt-accent) 7%, transparent);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(0, 0, 0, 0.1);\n\t--dt-success: rgba(22, 163, 74, 0.7);\n\t--dt-weekend-bg: rgba(0, 0, 0, 0.02);\n\t--dt-hover: rgba(0, 0, 0, 0.04);\n\t--dt-sans: inherit;\n\t--dt-mono: ui-monospace, 'SFMono-Regular', monospace;\n";
36
+ readonly midnight: "\n\t--dt-stage-bg: #080a0f;\n\t--dt-bg: #0b0e14;\n\t--dt-surface: #10141c;\n\t--dt-border: rgba(148, 163, 184, 0.07);\n\t--dt-border-day: rgba(148, 163, 184, 0.14);\n\t--dt-text: rgba(226, 232, 240, 0.85);\n\t--dt-text-2: rgba(148, 163, 184, 0.55);\n\t--dt-text-3: rgba(100, 116, 139, 0.55);\n\t--dt-accent: #ef4444;\n\t--dt-accent-dim: rgba(239, 68, 68, 0.18);\n\t--dt-glow: rgba(239, 68, 68, 0.35);\n\t--dt-today-bg: rgba(239, 68, 68, 0.07);\n\t--dt-btn-text: #fff;\n\t--dt-scrollbar: rgba(148, 163, 184, 0.12);\n\t--dt-success: rgba(74, 222, 128, 0.7);\n\t--dt-weekend-bg: rgba(148, 163, 184, 0.03);\n\t--dt-hover: rgba(148, 163, 184, 0.06);\n\t--dt-sans: inherit;\n\t--dt-mono: ui-monospace, 'SFMono-Regular', monospace;\n";
37
37
  };
38
38
  export type PresetName = keyof typeof presets;