kalendly 0.2.2 → 0.3.1

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/dist/index.umd.js CHANGED
@@ -78,12 +78,28 @@ var Kalendly = (() => {
78
78
  const max = maxYear ?? currentYear + 10;
79
79
  return Array.from({ length: max - min + 1 }, (_, i) => min + i);
80
80
  }
81
+ function eventCoversDate(event, date) {
82
+ const start = normalizeDate(new Date(event.date)).getTime();
83
+ const target = normalizeDate(date).getTime();
84
+ if (Number.isNaN(start)) return false;
85
+ if (event.endDate === void 0 || event.endDate === null) {
86
+ return start === target;
87
+ }
88
+ const end = normalizeDate(new Date(event.endDate)).getTime();
89
+ if (Number.isNaN(end)) {
90
+ throw new Error(
91
+ `<kal-calendar> event ${event.id} has an unreadable endDate: ${String(event.endDate)}.`
92
+ );
93
+ }
94
+ if (end < start) {
95
+ throw new Error(
96
+ `<kal-calendar> event ${event.id} has an endDate before its date: ${String(event.endDate)} < ${String(event.date)}.`
97
+ );
98
+ }
99
+ return target >= start && target <= end;
100
+ }
81
101
  function getEventsForDate(events, date) {
82
- const normalizedTargetDate = normalizeDate(date);
83
- return events.filter((event) => {
84
- const eventDate = normalizeDate(new Date(event.date));
85
- return eventDate.getTime() === normalizedTargetDate.getTime();
86
- });
102
+ return events.filter((event) => eventCoversDate(event, date));
87
103
  }
88
104
  function generateCalendarDates(year, month, events = [], weekStartsOn = 0) {
89
105
  const firstDay = new Date(year, month, 1);
@@ -139,26 +155,16 @@ var Kalendly = (() => {
139
155
  }
140
156
  return dates;
141
157
  }
142
- function getPopupPositionClass(selectedDayIndex) {
143
- if (selectedDayIndex === null) return "popup-center-bottom";
144
- if (selectedDayIndex < 3) {
145
- return "popup-right";
146
- } else if (selectedDayIndex > 4) {
147
- return "popup-left";
148
- } else {
149
- return "popup-center-bottom";
150
- }
151
- }
152
158
  function getCellClasses(calendarDate) {
153
159
  const classes = [];
154
160
  if (!calendarDate.isCurrentMonth) {
155
- classes.push("other-month");
161
+ classes.push("calendar-cell-other-month");
156
162
  }
157
163
  if (calendarDate.isToday) {
158
- classes.push("schedule--current--exam");
164
+ classes.push("calendar-cell-today");
159
165
  }
160
166
  if (calendarDate.hasEvents) {
161
- classes.push("has--event");
167
+ classes.push("calendar-cell-has-event");
162
168
  }
163
169
  return classes;
164
170
  }
@@ -208,6 +214,92 @@ var Kalendly = (() => {
208
214
  const color = colorMap[category] || colorMap.other || "#fc8917";
209
215
  return isValidHexColor(color) ? color : "#fc8917";
210
216
  }
217
+ var HTML_ESCAPES = {
218
+ "&": "&amp;",
219
+ "<": "&lt;",
220
+ ">": "&gt;",
221
+ '"': "&quot;",
222
+ "'": "&#39;"
223
+ };
224
+ function escapeHtml(value) {
225
+ if (value === null || value === void 0) return "";
226
+ return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
227
+ }
228
+ function slugifyToken(value) {
229
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
230
+ }
231
+ function safeUrl(value) {
232
+ const normalized = String(value).split("").filter((char) => char.charCodeAt(0) > 32).join("");
233
+ if (/^(?:https?:|mailto:)/i.test(normalized)) return normalized;
234
+ const schemeless = !/^[^/?#]*:/.test(normalized);
235
+ return schemeless ? normalized : "#";
236
+ }
237
+ function safeColor(value) {
238
+ const trimmed = String(value).trim();
239
+ return isValidHexColor(trimmed) || /^[a-z]+$/i.test(trimmed) ? trimmed : "#3b82f6";
240
+ }
241
+ var MINUTES_PER_DAY = 1440;
242
+ var DEFAULT_SLOT_DURATION = 60;
243
+ function parseTimeToMinutes(time) {
244
+ if (typeof time !== "string") return null;
245
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
246
+ if (!match) return null;
247
+ const hours = Number(match[1]);
248
+ const mins = Number(match[2]);
249
+ if (hours > 24 || mins > 59 || hours === 24 && mins > 0) return null;
250
+ return hours * 60 + mins;
251
+ }
252
+ function formatMinutes(total) {
253
+ const hours = Math.floor(total / 60);
254
+ const mins = total % 60;
255
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
256
+ }
257
+ function eventInterval(event, slotDuration, onDay) {
258
+ const day = normalizeDate(onDay).getTime();
259
+ if (Number.isNaN(day)) return null;
260
+ const base = day / 6e4;
261
+ const wholeDay = [base, base + MINUTES_PER_DAY];
262
+ if (event.allDay || !event.startTime) return wholeDay;
263
+ const from = parseTimeToMinutes(event.startTime);
264
+ if (from === null) return wholeDay;
265
+ if (event.endTime === void 0 || event.endTime === null) {
266
+ return [base + from, base + from + slotDuration];
267
+ }
268
+ const to = parseTimeToMinutes(event.endTime);
269
+ if (to === null) return wholeDay;
270
+ return [base + from, base + (to <= from ? to + MINUTES_PER_DAY : to)];
271
+ }
272
+ function mergeIntervals(intervals) {
273
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
274
+ return sorted.reduce((merged, [start, end]) => {
275
+ const last = merged[merged.length - 1];
276
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
277
+ else merged.push([start, end]);
278
+ return merged;
279
+ }, []);
280
+ }
281
+ function bookedSlots(events, date, slotDuration = DEFAULT_SLOT_DURATION) {
282
+ const base = normalizeDate(date).getTime() / 6e4;
283
+ const previousDay = new Date(date);
284
+ previousDay.setDate(previousDay.getDate() - 1);
285
+ const intervals = [];
286
+ for (const event of new Set(events)) {
287
+ for (const day of [previousDay, date]) {
288
+ if (!eventCoversDate(event, day)) continue;
289
+ const interval = eventInterval(event, slotDuration, day);
290
+ if (interval) intervals.push(interval);
291
+ }
292
+ }
293
+ const merged = mergeIntervals(intervals);
294
+ return Array.from(
295
+ { length: Math.floor(MINUTES_PER_DAY / slotDuration) },
296
+ (_, index) => {
297
+ const start = base + index * slotDuration;
298
+ const end = start + slotDuration;
299
+ return merged.some(([from, to]) => start < to && end > from);
300
+ }
301
+ );
302
+ }
211
303
 
212
304
  // src/core/calendar-engine.ts
213
305
  var CalendarEngine = class {
@@ -248,24 +340,38 @@ var Kalendly = (() => {
248
340
  * Get view model with computed properties
249
341
  */
250
342
  getViewModel() {
251
- const calendarDates = generateCalendarDates(
252
- this.state.currentYear,
253
- this.state.currentMonth,
254
- this.config.events,
255
- this.config.weekStartsOn
343
+ const panes = Array.from(
344
+ { length: Math.max(1, this.config.monthCount ?? 1) },
345
+ (_, offset) => {
346
+ const anchor = new Date(
347
+ this.state.currentYear,
348
+ this.state.currentMonth + offset,
349
+ 1
350
+ );
351
+ const year = anchor.getFullYear();
352
+ const month = anchor.getMonth();
353
+ return {
354
+ year,
355
+ month,
356
+ monthAndYearText: getMonthYearText(year, month),
357
+ calendarDates: generateCalendarDates(
358
+ year,
359
+ month,
360
+ this.config.events,
361
+ this.config.weekStartsOn
362
+ )
363
+ };
364
+ }
256
365
  );
257
366
  return {
258
367
  ...this.state,
259
368
  months: MONTHS,
260
369
  days: DAYS,
261
370
  years: generateYears(this.config.minYear, this.config.maxYear),
262
- monthAndYearText: getMonthYearText(
263
- this.state.currentYear,
264
- this.state.currentMonth
265
- ),
371
+ monthAndYearText: panes[0].monthAndYearText,
266
372
  scheduleDay: this.state.selectedDate ? formatDateForDisplay(this.state.selectedDate) : "",
267
- calendarDates,
268
- popupPositionClass: getPopupPositionClass(this.state.selectedDayIndex)
373
+ panes,
374
+ calendarDates: panes[0].calendarDates
269
375
  };
270
376
  }
271
377
  /**
@@ -439,7 +545,7 @@ var Kalendly = (() => {
439
545
 
440
546
  // src/web-components/CalendarElement.ts
441
547
  var isSameDay2 = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
442
- var CalendarElement = class extends HTMLElement {
548
+ var _CalendarElement = class _CalendarElement extends HTMLElement {
443
549
  constructor() {
444
550
  super(...arguments);
445
551
  this.engine = null;
@@ -460,6 +566,10 @@ var Kalendly = (() => {
460
566
  this._categoryColors = null;
461
567
  this._renderEvent = null;
462
568
  this._renderNoEvents = null;
569
+ this._availabilityColors = null;
570
+ this._selectableStatuses = null;
571
+ this._initError = null;
572
+ this.pendingUpdate = null;
463
573
  // Selection state (availability mode — range)
464
574
  this._rangeStart = null;
465
575
  this._rangeEnd = null;
@@ -472,6 +582,7 @@ var Kalendly = (() => {
472
582
  return this._events;
473
583
  }
474
584
  set events(val) {
585
+ this._initError = null;
475
586
  this._events = val;
476
587
  if (this.engine) {
477
588
  this.engine.updateEvents(val);
@@ -487,13 +598,28 @@ var Kalendly = (() => {
487
598
  this.engine.updateCategoryColors(val);
488
599
  }
489
600
  }
601
+ get availabilityColors() {
602
+ return this._availabilityColors ?? {};
603
+ }
604
+ set availabilityColors(val) {
605
+ this._initError = null;
606
+ this._availabilityColors = val;
607
+ if (this.engine) this.scheduleRender();
608
+ }
609
+ get selectableStatuses() {
610
+ return this._selectableStatuses ?? [];
611
+ }
612
+ set selectableStatuses(val) {
613
+ this._selectableStatuses = val;
614
+ if (this.engine) this.scheduleRender();
615
+ }
490
616
  set renderEvent(val) {
491
617
  this._renderEvent = val;
492
- if (this.engine) this.render();
618
+ if (this.engine) this.scheduleRender();
493
619
  }
494
620
  set renderNoEvents(val) {
495
621
  this._renderNoEvents = val;
496
- if (this.engine) this.render();
622
+ if (this.engine) this.scheduleRender();
497
623
  }
498
624
  get loading() {
499
625
  return this.hasAttribute("loading");
@@ -502,10 +628,22 @@ var Kalendly = (() => {
502
628
  if (val) this.setAttribute("loading", "");
503
629
  else this.removeAttribute("loading");
504
630
  }
631
+ // Custom element reactions report rather than propagate, so a failure here
632
+ // is kept and resurfaced at the next call the integrator makes
633
+ reaction(run) {
634
+ try {
635
+ run();
636
+ } catch (error) {
637
+ this._initError = error;
638
+ throw error;
639
+ }
640
+ }
505
641
  connectedCallback() {
506
642
  this.classList.add("kalendly-calendar");
507
- this.initEngine();
508
- this.render();
643
+ this.reaction(() => {
644
+ this.initEngine();
645
+ this.render();
646
+ });
509
647
  }
510
648
  disconnectedCallback() {
511
649
  this.cleanup();
@@ -515,7 +653,7 @@ var Kalendly = (() => {
515
653
  attributeChangedCallback(name, oldVal, newVal) {
516
654
  if (oldVal === newVal) return;
517
655
  if (name === "loading") {
518
- this.render();
656
+ this.scheduleRender();
519
657
  return;
520
658
  }
521
659
  if (name === "selectable" && newVal === null) {
@@ -528,6 +666,46 @@ var Kalendly = (() => {
528
666
  }
529
667
  this.reinit();
530
668
  }
669
+ get headingText() {
670
+ const heading = this.getAttribute("heading");
671
+ if (heading !== null) return heading;
672
+ const title = this.getAttribute("title");
673
+ if (title !== null && !_CalendarElement.titleDeprecationWarned) {
674
+ _CalendarElement.titleDeprecationWarned = true;
675
+ console.warn(
676
+ `<kal-calendar> the title attribute is deprecated and will be removed in a future release \u2014 use heading instead. title is a global HTML attribute, so the browser also renders it as a tooltip over the whole calendar.`
677
+ );
678
+ }
679
+ return title;
680
+ }
681
+ get slotDuration() {
682
+ const raw = this.getAttribute("slot-duration");
683
+ if (raw === null) return DEFAULT_SLOT_DURATION;
684
+ const minutes = Number(raw);
685
+ if (Number.isInteger(minutes) && minutes > 0 && MINUTES_PER_DAY % minutes === 0) {
686
+ return minutes;
687
+ }
688
+ console.warn(
689
+ `<kal-calendar> slot-duration="${raw}" must be a positive whole number of minutes that divides ${MINUTES_PER_DAY} \u2014 falling back to ${DEFAULT_SLOT_DURATION}.`
690
+ );
691
+ return DEFAULT_SLOT_DURATION;
692
+ }
693
+ warnMissingEndTime(events) {
694
+ for (const event of events) {
695
+ if (!event.startTime || event.endTime) continue;
696
+ const key = String(event.id);
697
+ if (_CalendarElement.warnedMissingEnd.has(key)) continue;
698
+ _CalendarElement.warnedMissingEnd.add(key);
699
+ console.warn(
700
+ `<kal-calendar> event ${key} has startTime but no endTime \u2014 occupying one ${this.slotDuration}-minute slot. Send an endTime to say how long it runs.`
701
+ );
702
+ }
703
+ }
704
+ get monthCount() {
705
+ const requested = Number(this.getAttribute("months") ?? 1);
706
+ if (!Number.isInteger(requested) || requested < 1) return 1;
707
+ return Math.min(requested, 2);
708
+ }
531
709
  get minYear() {
532
710
  const val = this.getAttribute("min-year");
533
711
  return val ? parseInt(val, 10) : (/* @__PURE__ */ new Date()).getFullYear() - 30;
@@ -547,19 +725,20 @@ var Kalendly = (() => {
547
725
  minYear: this.minYear,
548
726
  maxYear: this.maxYear,
549
727
  weekStartsOn,
550
- categoryColors: this._categoryColors ?? void 0
728
+ categoryColors: this._categoryColors ?? void 0,
729
+ monthCount: this.monthCount
551
730
  });
552
731
  this.actions = this.engine.getActions();
553
732
  this.applyTheme();
554
733
  this.unsubscribe = this.engine.subscribe(() => {
555
- this.render();
734
+ this.scheduleRender();
556
735
  });
557
736
  }
558
737
  reinit() {
559
738
  if (!this.engine) return;
560
739
  this.cleanup();
561
740
  this.initEngine();
562
- this.render();
741
+ this.scheduleRender();
563
742
  }
564
743
  cleanup() {
565
744
  if (this.unsubscribe) {
@@ -596,42 +775,84 @@ var Kalendly = (() => {
596
775
  applyTheme() {
597
776
  if (!this._theme) return;
598
777
  const root = document.documentElement;
599
- const t = this._theme;
600
- if (t.primary)
601
- root.style.setProperty("--calendar-primary-color", t.primary);
602
- if (t.secondary)
603
- root.style.setProperty("--calendar-secondary-color", t.secondary);
604
- if (t.tertiary)
605
- root.style.setProperty("--calendar-tertiary-color", t.tertiary);
606
- if (t.textColor)
607
- root.style.setProperty("--calendar-text-color", t.textColor);
608
- if (t.textLight)
609
- root.style.setProperty("--calendar-text-light", t.textLight);
610
- if (t.background)
611
- root.style.setProperty("--calendar-background", t.background);
612
- if (t.cellHover)
613
- root.style.setProperty("--calendar-cell-hover", t.cellHover);
614
- if (t.borderColor)
615
- root.style.setProperty("--calendar-border-color", t.borderColor);
616
- if (t.todayOutline)
617
- root.style.setProperty("--calendar-today-outline", t.todayOutline);
618
- if (t.selectedBg)
619
- root.style.setProperty("--calendar-selected-bg", t.selectedBg);
620
- if (t.headerBg) root.style.setProperty("--calendar-header-bg", t.headerBg);
621
- if (t.popupBg) root.style.setProperty("--calendar-popup-bg", t.popupBg);
622
- if (t.eventIndicator)
623
- root.style.setProperty("--calendar-event-indicator", t.eventIndicator);
778
+ for (const key of Object.keys(_CalendarElement.themeMap)) {
779
+ const value = this._theme[key];
780
+ if (value) root.style.setProperty(_CalendarElement.themeMap[key], value);
781
+ }
782
+ }
783
+ get knownBuckets() {
784
+ return [
785
+ ..._CalendarElement.BUILT_IN_BUCKETS,
786
+ ...Object.keys(this._availabilityColors ?? {})
787
+ ];
788
+ }
789
+ get updateComplete() {
790
+ return this.pendingUpdate ?? Promise.resolve();
791
+ }
792
+ scheduleRender() {
793
+ if (this.pendingUpdate) return;
794
+ this.pendingUpdate = Promise.resolve().then(() => {
795
+ this.pendingUpdate = null;
796
+ try {
797
+ this.render();
798
+ } catch (error) {
799
+ this._initError = error;
800
+ throw error;
801
+ }
802
+ });
803
+ }
804
+ rethrowInitError() {
805
+ if (this._initError) throw this._initError;
806
+ }
807
+ assertStatusesDeclared() {
808
+ if (!this.getAttribute("availability-mode")) return;
809
+ const missing = this._events.filter(
810
+ (event) => typeof event.availabilityStatus !== "string" || event.availabilityStatus === ""
811
+ ).map((event) => String(event.id));
812
+ if (missing.length) {
813
+ throw new Error(
814
+ `<kal-calendar> availability-mode requires availabilityStatus on every event. Missing on: ${missing.join(", ")}.`
815
+ );
816
+ }
817
+ }
818
+ assertStatusesKnown() {
819
+ const known = new Set(this.knownBuckets);
820
+ const unknown = this._events.filter((event) => !known.has(event.availabilityStatus)).map((event) => `${event.id} (${event.availabilityStatus})`);
821
+ if (unknown.length) {
822
+ throw new Error(
823
+ `<kal-calendar> availabilityStatus must name a built-in bucket (${_CalendarElement.BUILT_IN_BUCKETS.join(", ")}) or a key of availabilityColors. Unrecognised on: ${unknown.join(", ")}.`
824
+ );
825
+ }
826
+ }
827
+ resolveBucket(date) {
828
+ if (!this.engine) return "open";
829
+ const declared = new Set(
830
+ this.engine.getEventsForDate(date).map((event) => event.availabilityStatus)
831
+ );
832
+ if (declared.size === 0) return "open";
833
+ return this.knownBuckets.find((bucket) => declared.has(bucket));
834
+ }
835
+ isDateSelectable(date) {
836
+ if (!this.engine) return false;
837
+ if (this._selectableStatuses) {
838
+ return this._selectableStatuses.includes(this.resolveBucket(date));
839
+ }
840
+ return this.engine.getEventsForDate(date).length === 0;
624
841
  }
625
842
  render() {
626
843
  if (!this.engine) return;
627
844
  const viewModel = this.engine.getViewModel();
628
845
  const useShortMonths = this.hasAttribute("use-short-month-names");
629
- const title = this.getAttribute("title");
846
+ const title = this.headingText;
630
847
  const minYear = this.minYear;
631
848
  const maxYear = this.maxYear;
632
849
  const availabilityMode = this.getAttribute("availability-mode");
633
850
  const selectable = this.hasAttribute("selectable");
634
851
  const isLoading = this.loading;
852
+ if (availabilityMode) {
853
+ this.assertStatusesDeclared();
854
+ this.assertStatusesKnown();
855
+ }
635
856
  const today = /* @__PURE__ */ new Date();
636
857
  const todayMonth = today.getMonth();
637
858
  const todayYear = today.getFullYear();
@@ -640,7 +861,7 @@ var Kalendly = (() => {
640
861
  const defaultRenderEvent = (event) => {
641
862
  const timeRange = formatTimeRange(event);
642
863
  const attendeesList = formatAttendees(event.attendees);
643
- let borderColor = event.color || "#3b82f6";
864
+ let borderColor = safeColor(event.color || "#3b82f6");
644
865
  if (event.category) {
645
866
  borderColor = this.engine.getCategoryColor(event.category);
646
867
  }
@@ -675,56 +896,56 @@ var Kalendly = (() => {
675
896
  return `
676
897
  <div class="event-card" style="border-left-color: ${borderColor}">
677
898
  <div class="event-header">
678
- <div class="event-title">${event.name}</div>
899
+ <div class="event-title">${escapeHtml(event.name)}</div>
679
900
  <div class="event-badges">
680
- ${event.category ? `<span class="badge category-${event.category}">${getCategoryLabel(event.category)}</span>` : ""}
681
- ${event.priority ? `<span class="badge priority-${event.priority}">${getPriorityLabel(event.priority)}</span>` : ""}
682
- ${event.status && event.status !== "scheduled" ? `<span class="badge status-${event.status}">${getStatusLabel(event.status)}</span>` : ""}
901
+ ${event.category ? `<span class="badge category category-${slugifyToken(event.category)}">${escapeHtml(getCategoryLabel(event.category))}</span>` : ""}
902
+ ${event.priority ? `<span class="badge priority priority-${slugifyToken(event.priority)}">${escapeHtml(getPriorityLabel(event.priority))}</span>` : ""}
903
+ ${event.status && event.status !== "scheduled" ? `<span class="badge status status-${slugifyToken(event.status)}">${escapeHtml(getStatusLabel(event.status))}</span>` : ""}
683
904
  </div>
684
905
  </div>
685
906
 
686
907
  ${timeRange ? `
687
908
  <div class="event-time">
688
909
  <span class="event-time-label">Time:</span>
689
- <span class="event-time-value">${timeRange}</span>
910
+ <span class="event-time-value">${escapeHtml(timeRange)}</span>
690
911
  </div>
691
912
  ` : ""}
692
913
 
693
914
  ${event.description ? `
694
- <div class="event-description">${event.description}</div>
915
+ <div class="event-description">${escapeHtml(event.description)}</div>
695
916
  ` : ""}
696
917
 
697
918
  ${event.location ? `
698
919
  <div class="event-time">
699
920
  <span class="event-time-label">Location:</span>
700
- <span class="event-time-value">${event.location}</span>
921
+ <span class="event-time-value">${escapeHtml(event.location)}</span>
701
922
  </div>
702
923
  ` : ""}
703
924
 
704
925
  ${attendeesList ? `
705
926
  <div class="event-time">
706
927
  <span class="event-time-label">Attendees:</span>
707
- <span class="event-time-value">${attendeesList}</span>
928
+ <span class="event-time-value">${escapeHtml(attendeesList)}</span>
708
929
  </div>
709
930
  ` : ""}
710
931
 
711
932
  ${event.organizer ? `
712
933
  <div class="event-time">
713
934
  <span class="event-time-label">Organizer:</span>
714
- <span class="event-time-value">${event.organizer}</span>
935
+ <span class="event-time-value">${escapeHtml(event.organizer)}</span>
715
936
  </div>
716
937
  ` : ""}
717
938
 
718
939
  ${event.notes ? `
719
940
  <div class="event-time">
720
941
  <span class="event-time-label">Notes:</span>
721
- <span class="event-time-value">${event.notes}</span>
942
+ <span class="event-time-value">${escapeHtml(event.notes)}</span>
722
943
  </div>
723
944
  ` : ""}
724
945
 
725
946
  ${event.url ? `
726
947
  <div class="event-time">
727
- <a href="${event.url}" target="_blank" rel="noopener noreferrer" class="event-link">
948
+ <a href="${escapeHtml(safeUrl(event.url))}" target="_blank" rel="noopener noreferrer" class="event-link">
728
949
  View Details \u2192
729
950
  </a>
730
951
  </div>
@@ -732,7 +953,7 @@ var Kalendly = (() => {
732
953
 
733
954
  ${event.tags && event.tags.length > 0 ? `
734
955
  <div class="event-tags">
735
- ${event.tags.map((tag) => `<span class="event-tag">${tag}</span>`).join("")}
956
+ ${event.tags.map((tag) => `<span class="event-tag">${escapeHtml(tag)}</span>`).join("")}
736
957
  </div>
737
958
  ` : ""}
738
959
  </div>
@@ -741,97 +962,181 @@ var Kalendly = (() => {
741
962
  const defaultRenderNoEvents = () => '<div class="no-events-message">No events scheduled for this day.</div>';
742
963
  const renderEvent = this._renderEvent || defaultRenderEvent;
743
964
  const renderNoEvents = this._renderNoEvents || defaultRenderNoEvents;
744
- const renderTimeGrid = (events, date) => {
745
- const isHourBooked = (hour) => events.some((event) => {
746
- if (!event.startTime || !event.endTime) return true;
747
- const [startH] = event.startTime.split(":").map(Number);
748
- const [endH] = event.endTime.split(":").map(Number);
749
- return hour >= startH && hour < endH;
750
- });
751
- const slots = Array.from({ length: 24 }, (_, hour) => {
752
- const startTime = `${String(hour).padStart(2, "0")}:00`;
753
- const endTime = `${String(hour + 1).padStart(2, "0")}:00`;
754
- const booked = isHourBooked(hour);
755
- const inTimeRange = !booked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
965
+ const renderTimeGrid = (date) => {
966
+ const slotDuration = this.slotDuration;
967
+ const previousDay = new Date(date);
968
+ previousDay.setDate(previousDay.getDate() - 1);
969
+ const events = [
970
+ ...this.engine.getEventsForDate(previousDay),
971
+ ...this.engine.getEventsForDate(date)
972
+ ];
973
+ this.warnMissingEndTime(events);
974
+ const booked = bookedSlots(events, date, slotDuration);
975
+ const slots = booked.map((isBooked, index) => {
976
+ const startTime = formatMinutes(index * slotDuration);
977
+ const endTime = formatMinutes((index + 1) * slotDuration);
978
+ const inTimeRange = !isBooked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
756
979
  const isRangeStart = inTimeRange && startTime === this._timeRangeStart;
757
980
  const isRangeEnd = inTimeRange && endTime === this._timeRangeEnd;
758
981
  const isInRange = inTimeRange && !isRangeStart && !isRangeEnd;
759
982
  const slotClasses = [
760
- "time-grid__slot",
761
- booked ? "time-grid__slot--booked" : "time-grid__slot--free",
762
- isRangeStart ? "time-grid__slot--range-start" : "",
763
- isRangeEnd ? "time-grid__slot--range-end" : "",
764
- isInRange ? "time-grid__slot--in-range" : ""
983
+ "time-grid-slot",
984
+ isBooked ? "time-grid-slot-blocked" : "time-grid-slot-open",
985
+ isRangeStart ? "time-grid-slot-range-start" : "",
986
+ isRangeEnd ? "time-grid-slot-range-end" : "",
987
+ isInRange ? "time-grid-slot-in-range" : ""
765
988
  ].filter(Boolean).join(" ");
766
- const slotAttrs = !booked && selectable ? `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}"` : "";
989
+ const slotAttrs = `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}" data-booked="${isBooked}"`;
767
990
  return `
768
991
  <div class="${slotClasses}" ${slotAttrs}>
769
- <span class="time-grid__label">${startTime}</span>
770
- <span class="time-grid__status">${booked ? "Booked" : "Available"}</span>
992
+ <span class="time-grid-label">${startTime}</span>
993
+ <span class="time-grid-status">${isBooked ? "Booked" : "Available"}</span>
771
994
  </div>`;
772
995
  });
773
- return `<div class="time-grid">${slots.join("")}</div>`;
996
+ const gridClasses = selectable ? "time-grid time-grid-selectable" : "time-grid";
997
+ return `<div class="${gridClasses}">${slots.join("")}</div>`;
774
998
  };
999
+ const multiMonth = viewModel.panes.length > 1;
1000
+ const renderPane = (pane) => `
1001
+ <div class="calendar-pane">
1002
+ ${multiMonth ? `<div class="calendar-pane-caption">${escapeHtml(pane.monthAndYearText)}</div>` : ""}
1003
+ <table class="calendar-table calendar-table-bordered">
1004
+ <thead>
1005
+ <tr>
1006
+ ${viewModel.days.map((day) => `<th>${day.slice(0, 3)}</th>`).join("")}
1007
+ </tr>
1008
+ </thead>
1009
+ <tbody data-calendar-body>
1010
+ ${isLoading ? Array.from(
1011
+ { length: 6 },
1012
+ () => `<tr>${Array.from(
1013
+ { length: 7 },
1014
+ () => `<td class="calendar-skeleton" aria-hidden="true"></td>`
1015
+ ).join("")}</tr>`
1016
+ ).join("") : pane.calendarDates.map(
1017
+ (week) => `
1018
+ <tr>
1019
+ ${week.map((calendarDate, dayIndex) => {
1020
+ const classes = getCellClasses(calendarDate);
1021
+ const cellAttrs = [];
1022
+ if (viewModel.selectedDate && isSameDay2(
1023
+ calendarDate.date,
1024
+ viewModel.selectedDate
1025
+ )) {
1026
+ classes.push("calendar-cell-selected");
1027
+ }
1028
+ if (availabilityMode && calendarDate.isCurrentMonth) {
1029
+ const bucket = this.resolveBucket(
1030
+ calendarDate.date
1031
+ );
1032
+ const custom = (this._availabilityColors ?? {})[bucket];
1033
+ classes.push(
1034
+ `availability-${slugifyToken(bucket)}`
1035
+ );
1036
+ if (custom) {
1037
+ classes.push("availability-status");
1038
+ cellAttrs.push(
1039
+ `style="--availability-color: ${escapeHtml(safeColor(custom))}"`
1040
+ );
1041
+ }
1042
+ if (!this.isDateSelectable(calendarDate.date)) {
1043
+ classes.push("availability-unselectable");
1044
+ }
1045
+ cellAttrs.push(
1046
+ `aria-label="${escapeHtml(bucket)}"`
1047
+ );
1048
+ }
1049
+ if (availabilityMode === "day" && selectable && calendarDate.isCurrentMonth) {
1050
+ const d = calendarDate.date;
1051
+ if (this._rangeStart && isSameDay2(d, this._rangeStart))
1052
+ classes.push("availability-range-start");
1053
+ if (this._rangeEnd && isSameDay2(d, this._rangeEnd))
1054
+ classes.push("availability-range-end");
1055
+ if (this._rangeStart && this._rangeEnd) {
1056
+ if (d > this._rangeStart && d < this._rangeEnd)
1057
+ classes.push("availability-in-range");
1058
+ }
1059
+ }
1060
+ const dateString = calendarDate.date.toISOString();
1061
+ return `
1062
+ <td
1063
+ class="${classes.join(" ")}"
1064
+ data-date="${dateString}"
1065
+ data-day-index="${dayIndex}"
1066
+ data-clickable="true"
1067
+ ${cellAttrs.join(" ")}
1068
+ >
1069
+ ${calendarDate.date.getDate()}
1070
+ </td>
1071
+ `;
1072
+ }).join("")}
1073
+ </tr>
1074
+ `
1075
+ ).join("")}
1076
+ </tbody>
1077
+ </table>
1078
+ </div>
1079
+ `;
775
1080
  const html = `
776
1081
  ${title ? `
777
- <div class="page--title">
778
- <h1>${title}</h1>
1082
+ <div class="calendar-title">
1083
+ <h1>${escapeHtml(title)}</h1>
779
1084
  </div>
780
1085
  ` : ""}
781
1086
 
782
- <div class="calendar--content">
783
- <div class="calendar--card">
784
- <div class="calendar--nav-header">
785
- <button type="button" class="calendar--nav-arrow" data-action="previous" aria-label="Previous month">
1087
+ <div class="calendar-content">
1088
+ <div class="calendar-card">
1089
+ <div class="calendar-nav-header">
1090
+ <button type="button" class="calendar-nav-arrow" data-action="previous" aria-label="Previous month">
786
1091
  &#8249;
787
1092
  </button>
788
1093
 
789
- <div class="calendar--picker-container" data-picker-container>
1094
+ <div class="calendar-picker-container" data-picker-container>
790
1095
  <button
791
1096
  type="button"
792
- class="calendar--picker-btn"
1097
+ class="calendar-picker-btn"
793
1098
  data-action="toggle-picker"
794
1099
  aria-expanded="${this.pickerOpen ? "true" : "false"}"
795
1100
  aria-haspopup="true"
796
1101
  >
797
1102
  ${useShortMonths ? `${MONTHS[viewModel.currentMonth]} ${viewModel.currentYear}` : viewModel.monthAndYearText}
798
- <span class="calendar--picker-chevron">&#9662;</span>
1103
+ <span class="calendar-picker-chevron">&#9662;</span>
799
1104
  </button>
800
1105
 
801
1106
  ${this.pickerOpen ? `
802
- <div class="calendar--picker-dropdown">
803
- <div class="calendar--picker-year-row">
1107
+ <div class="calendar-picker-dropdown">
1108
+ <div class="calendar-picker-year-row">
804
1109
  <button
805
1110
  type="button"
806
- class="calendar--picker-year-arrow"
1111
+ class="calendar-picker-year-arrow"
807
1112
  data-action="year-prev"
808
1113
  ${viewModel.currentYear <= minYear ? "disabled" : ""}
809
1114
  aria-label="Previous year"
810
1115
  >&#8249;</button>
811
1116
  <input
812
1117
  type="text"
813
- class="calendar--picker-year-input${!this.yearInputValid ? " invalid" : ""}"
814
- value="${this.yearInput || viewModel.currentYear}"
1118
+ class="calendar-picker-year-input${!this.yearInputValid ? " invalid" : ""}"
1119
+ value="${escapeHtml(this.yearInput || viewModel.currentYear)}"
815
1120
  data-year-input
816
1121
  aria-label="Year"
817
1122
  />
818
1123
  <button
819
1124
  type="button"
820
- class="calendar--picker-year-arrow"
1125
+ class="calendar-picker-year-arrow"
821
1126
  data-action="year-next"
822
1127
  ${viewModel.currentYear >= maxYear ? "disabled" : ""}
823
1128
  aria-label="Next year"
824
1129
  >&#8250;</button>
825
1130
  </div>
826
1131
 
827
- <div class="calendar--picker-months">
1132
+ <div class="calendar-picker-months">
828
1133
  ${(useShortMonths ? MONTHS : MONTHS_FULL).map((month, index) => {
829
1134
  const isSelected = index === viewModel.currentMonth;
830
1135
  const isCurrent = index === todayMonth && viewModel.currentYear === todayYear;
831
1136
  return `
832
1137
  <button
833
1138
  type="button"
834
- class="calendar--picker-month${isSelected ? " selected" : ""}${isCurrent ? " current-month" : ""}"
1139
+ class="calendar-picker-month${isSelected ? " selected" : ""}${isCurrent ? " current-month" : ""}"
835
1140
  data-action="select-month"
836
1141
  data-month="${index}"
837
1142
  >${month}</button>
@@ -844,70 +1149,22 @@ var Kalendly = (() => {
844
1149
 
845
1150
  <button
846
1151
  type="button"
847
- class="calendar--today-btn"
1152
+ class="calendar-today-btn"
848
1153
  data-action="today"
849
1154
  ${isCurrentMonth ? "disabled" : ""}
850
1155
  >Today</button>
851
1156
 
852
- <button type="button" class="calendar--nav-arrow" data-action="next" aria-label="Next month">
1157
+ <button type="button" class="calendar-nav-arrow" data-action="next" aria-label="Next month">
853
1158
  &#8250;
854
1159
  </button>
855
1160
  </div>
856
1161
 
857
- <table class="calendar--table calendar--table--bordered">
858
- <thead>
859
- <tr>
860
- ${viewModel.days.map((day) => `<th>${day.slice(0, 3)}</th>`).join("")}
861
- </tr>
862
- </thead>
863
- <tbody data-calendar-body>
864
- ${isLoading ? Array.from(
865
- { length: 6 },
866
- () => `<tr>${Array.from(
867
- { length: 7 },
868
- () => `<td class="calendar--skeleton" aria-hidden="true"></td>`
869
- ).join("")}</tr>`
870
- ).join("") : viewModel.calendarDates.map(
871
- (week) => `
872
- <tr>
873
- ${week.map((calendarDate, dayIndex) => {
874
- const classes = getCellClasses(calendarDate);
875
- if (availabilityMode && calendarDate.isCurrentMonth) {
876
- classes.push(
877
- calendarDate.hasEvents ? "availability--booked" : "availability--free"
878
- );
879
- }
880
- if (availabilityMode === "day" && selectable && calendarDate.isCurrentMonth) {
881
- const d = calendarDate.date;
882
- if (this._rangeStart && isSameDay2(d, this._rangeStart))
883
- classes.push("availability--range-start");
884
- if (this._rangeEnd && isSameDay2(d, this._rangeEnd))
885
- classes.push("availability--range-end");
886
- if (this._rangeStart && this._rangeEnd) {
887
- if (d > this._rangeStart && d < this._rangeEnd)
888
- classes.push("availability--in-range");
889
- }
890
- }
891
- const dateString = calendarDate.date.toISOString();
892
- return `
893
- <td
894
- class="${classes.join(" ")}"
895
- data-date="${dateString}"
896
- data-day-index="${dayIndex}"
897
- data-clickable="true"
898
- >
899
- ${calendarDate.date.getDate()}
900
- </td>
901
- `;
902
- }).join("")}
903
- </tr>
904
- `
905
- ).join("")}
906
- </tbody>
907
- </table>
1162
+ <div class="calendar-panes">
1163
+ ${viewModel.panes.map(renderPane).join("")}
1164
+ </div>
908
1165
 
909
1166
  ${!isLoading && availabilityMode !== "day" && viewModel.selectedDate ? `
910
- <div class="date-popup ${viewModel.popupPositionClass}">
1167
+ <div class="date-popup">
911
1168
  <div class="popup-header">
912
1169
  <h2>${viewModel.scheduleDay}</h2>
913
1170
  <button type="button" class="popup-close" data-action="close-popup" aria-label="Close">\u2715</button>
@@ -920,7 +1177,7 @@ var Kalendly = (() => {
920
1177
  ` : ""}
921
1178
 
922
1179
  <div class="events-container">
923
- ${availabilityMode === "time" ? renderTimeGrid(viewModel.tasks, viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1180
+ ${availabilityMode === "time" ? renderTimeGrid(viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
924
1181
  </div>
925
1182
  </div>
926
1183
  ` : ""}
@@ -945,8 +1202,7 @@ var Kalendly = (() => {
945
1202
  const availMode = this.getAttribute("availability-mode");
946
1203
  const isSelectable = this.hasAttribute("selectable");
947
1204
  if (availMode === "day" && isSelectable) {
948
- const isBooked = cell.classList.contains("availability--booked");
949
- if (!isBooked) {
1205
+ if (this.isDateSelectable(date)) {
950
1206
  let startDate;
951
1207
  let endDate;
952
1208
  if (this._rangeEnd !== null) {
@@ -964,7 +1220,7 @@ var Kalendly = (() => {
964
1220
  cursor.setDate(cursor.getDate() + 1);
965
1221
  let blocked = false;
966
1222
  while (cursor < e2) {
967
- if (this.engine.getEventsForDate(cursor).length > 0) {
1223
+ if (!this.isDateSelectable(cursor)) {
968
1224
  blocked = true;
969
1225
  break;
970
1226
  }
@@ -1082,6 +1338,20 @@ var Kalendly = (() => {
1082
1338
  const slotStart = actionEl.dataset.startTime;
1083
1339
  const slotEnd = actionEl.dataset.endTime;
1084
1340
  const slotDate = new Date(actionEl.dataset.date);
1341
+ const slotBooked = actionEl.dataset.booked === "true";
1342
+ this.dispatchEvent(
1343
+ new CustomEvent("cal-slot-select", {
1344
+ bubbles: true,
1345
+ composed: true,
1346
+ detail: {
1347
+ date: slotDate,
1348
+ startTime: slotStart,
1349
+ endTime: slotEnd,
1350
+ booked: slotBooked
1351
+ }
1352
+ })
1353
+ );
1354
+ if (slotBooked || !this.hasAttribute("selectable")) break;
1085
1355
  if (this._timeRangeComplete) {
1086
1356
  this._timeRangeDate = slotDate;
1087
1357
  this._timeRangeStart = slotStart;
@@ -1198,31 +1468,86 @@ var Kalendly = (() => {
1198
1468
  this.theme = theme;
1199
1469
  }
1200
1470
  getCurrentDate() {
1471
+ this.rethrowInitError();
1201
1472
  return this.engine?.getViewModel().selectedDate ?? null;
1202
1473
  }
1203
1474
  goToDate(date) {
1475
+ this.rethrowInitError();
1204
1476
  const year = date.getFullYear();
1205
1477
  const month = date.getMonth();
1206
1478
  this.dispatchMonthChange(year, month);
1207
1479
  this.actions?.jump(year, month);
1208
1480
  }
1209
1481
  getEngine() {
1482
+ this.rethrowInitError();
1210
1483
  if (!this.engine)
1211
1484
  throw new Error("CalendarElement is not connected to the DOM");
1212
1485
  return this.engine;
1213
1486
  }
1214
1487
  };
1215
- CalendarElement.observedAttributes = [
1488
+ _CalendarElement.observedAttributes = [
1216
1489
  "initial-date",
1217
1490
  "min-year",
1218
1491
  "max-year",
1219
1492
  "week-starts-on",
1493
+ "heading",
1494
+ "months",
1495
+ "slot-duration",
1220
1496
  "title",
1221
1497
  "use-short-month-names",
1222
1498
  "availability-mode",
1223
1499
  "selectable",
1224
1500
  "loading"
1225
1501
  ];
1502
+ _CalendarElement.themeMap = {
1503
+ primary: "--calendar-primary-color",
1504
+ secondary: "--calendar-secondary-color",
1505
+ tertiary: "--calendar-tertiary-color",
1506
+ textColor: "--calendar-text-color",
1507
+ textLight: "--calendar-text-light",
1508
+ background: "--calendar-background",
1509
+ cellHover: "--calendar-cell-hover",
1510
+ borderColor: "--calendar-border-color",
1511
+ todayOutline: "--calendar-today-outline",
1512
+ selectedBg: "--calendar-selected-bg",
1513
+ headerBg: "--calendar-header-bg",
1514
+ popupBg: "--calendar-popup-bg",
1515
+ pickerBg: "--calendar-picker-bg",
1516
+ pickerShadow: "--calendar-picker-shadow",
1517
+ eventIndicator: "--calendar-event-indicator",
1518
+ onAccent: "--calendar-on-accent",
1519
+ link: "--calendar-link",
1520
+ openBg: "--calendar-open-bg",
1521
+ openFg: "--calendar-open-fg",
1522
+ conditionalBg: "--calendar-conditional-bg",
1523
+ conditionalFg: "--calendar-conditional-fg",
1524
+ blockedBg: "--calendar-blocked-bg",
1525
+ blockedFg: "--calendar-blocked-fg",
1526
+ rangeBg: "--calendar-range-bg",
1527
+ rangeOutline: "--calendar-range-outline",
1528
+ inRangeBg: "--calendar-in-range-bg",
1529
+ inRangeOutline: "--calendar-in-range-outline",
1530
+ badgeBg: "--calendar-badge-bg",
1531
+ badgeText: "--calendar-badge-text",
1532
+ badgeSuccessBg: "--calendar-badge-success-bg",
1533
+ badgeSuccessText: "--calendar-badge-success-text",
1534
+ badgeInfoBg: "--calendar-badge-info-bg",
1535
+ badgeInfoText: "--calendar-badge-info-text",
1536
+ badgeWarningBg: "--calendar-badge-warning-bg",
1537
+ badgeWarningText: "--calendar-badge-warning-text",
1538
+ badgeDangerBg: "--calendar-badge-danger-bg",
1539
+ badgeDangerText: "--calendar-badge-danger-text",
1540
+ badgeNeutralBg: "--calendar-badge-neutral-bg",
1541
+ badgeNeutralText: "--calendar-badge-neutral-text",
1542
+ badgePositiveBg: "--calendar-badge-positive-bg",
1543
+ badgePositiveText: "--calendar-badge-positive-text",
1544
+ badgeTentativeBg: "--calendar-badge-tentative-bg",
1545
+ badgeTentativeText: "--calendar-badge-tentative-text"
1546
+ };
1547
+ _CalendarElement.warnedMissingEnd = /* @__PURE__ */ new Set();
1548
+ _CalendarElement.BUILT_IN_BUCKETS = ["blocked", "conditional", "open"];
1549
+ _CalendarElement.titleDeprecationWarned = false;
1550
+ var CalendarElement = _CalendarElement;
1226
1551
  function defineCalendarElement(tagName = "kal-calendar") {
1227
1552
  if (typeof customElements !== "undefined" && !customElements.get(tagName)) {
1228
1553
  customElements.define(tagName, CalendarElement);