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.mjs CHANGED
@@ -31,12 +31,28 @@ function generateYears(minYear, maxYear) {
31
31
  const max = maxYear ?? currentYear + 10;
32
32
  return Array.from({ length: max - min + 1 }, (_, i) => min + i);
33
33
  }
34
+ function eventCoversDate(event, date) {
35
+ const start = normalizeDate(new Date(event.date)).getTime();
36
+ const target = normalizeDate(date).getTime();
37
+ if (Number.isNaN(start)) return false;
38
+ if (event.endDate === void 0 || event.endDate === null) {
39
+ return start === target;
40
+ }
41
+ const end = normalizeDate(new Date(event.endDate)).getTime();
42
+ if (Number.isNaN(end)) {
43
+ throw new Error(
44
+ `<kal-calendar> event ${event.id} has an unreadable endDate: ${String(event.endDate)}.`
45
+ );
46
+ }
47
+ if (end < start) {
48
+ throw new Error(
49
+ `<kal-calendar> event ${event.id} has an endDate before its date: ${String(event.endDate)} < ${String(event.date)}.`
50
+ );
51
+ }
52
+ return target >= start && target <= end;
53
+ }
34
54
  function getEventsForDate(events, date) {
35
- const normalizedTargetDate = normalizeDate(date);
36
- return events.filter((event) => {
37
- const eventDate = normalizeDate(new Date(event.date));
38
- return eventDate.getTime() === normalizedTargetDate.getTime();
39
- });
55
+ return events.filter((event) => eventCoversDate(event, date));
40
56
  }
41
57
  function hasEvents(events, date) {
42
58
  return getEventsForDate(events, date).length > 0;
@@ -95,26 +111,16 @@ function generateCalendarDates(year, month, events = [], weekStartsOn = 0) {
95
111
  }
96
112
  return dates;
97
113
  }
98
- function getPopupPositionClass(selectedDayIndex) {
99
- if (selectedDayIndex === null) return "popup-center-bottom";
100
- if (selectedDayIndex < 3) {
101
- return "popup-right";
102
- } else if (selectedDayIndex > 4) {
103
- return "popup-left";
104
- } else {
105
- return "popup-center-bottom";
106
- }
107
- }
108
114
  function getCellClasses(calendarDate) {
109
115
  const classes = [];
110
116
  if (!calendarDate.isCurrentMonth) {
111
- classes.push("other-month");
117
+ classes.push("calendar-cell-other-month");
112
118
  }
113
119
  if (calendarDate.isToday) {
114
- classes.push("schedule--current--exam");
120
+ classes.push("calendar-cell-today");
115
121
  }
116
122
  if (calendarDate.hasEvents) {
117
- classes.push("has--event");
123
+ classes.push("calendar-cell-has-event");
118
124
  }
119
125
  return classes;
120
126
  }
@@ -173,7 +179,84 @@ function getCategoryColor(category, customColors) {
173
179
  const color = colorMap[category] || colorMap.other || "#fc8917";
174
180
  return isValidHexColor(color) ? color : "#fc8917";
175
181
  }
176
- var MONTHS, MONTHS_FULL, DAYS, DEFAULT_CATEGORY_COLORS;
182
+ function escapeHtml(value) {
183
+ if (value === null || value === void 0) return "";
184
+ return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
185
+ }
186
+ function slugifyToken(value) {
187
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
188
+ }
189
+ function safeUrl(value) {
190
+ const normalized = String(value).split("").filter((char) => char.charCodeAt(0) > 32).join("");
191
+ if (/^(?:https?:|mailto:)/i.test(normalized)) return normalized;
192
+ const schemeless = !/^[^/?#]*:/.test(normalized);
193
+ return schemeless ? normalized : "#";
194
+ }
195
+ function safeColor(value) {
196
+ const trimmed = String(value).trim();
197
+ return isValidHexColor(trimmed) || /^[a-z]+$/i.test(trimmed) ? trimmed : "#3b82f6";
198
+ }
199
+ function parseTimeToMinutes(time) {
200
+ if (typeof time !== "string") return null;
201
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
202
+ if (!match) return null;
203
+ const hours = Number(match[1]);
204
+ const mins = Number(match[2]);
205
+ if (hours > 24 || mins > 59 || hours === 24 && mins > 0) return null;
206
+ return hours * 60 + mins;
207
+ }
208
+ function formatMinutes(total) {
209
+ const hours = Math.floor(total / 60);
210
+ const mins = total % 60;
211
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
212
+ }
213
+ function eventInterval(event, slotDuration, onDay) {
214
+ const day = normalizeDate(onDay).getTime();
215
+ if (Number.isNaN(day)) return null;
216
+ const base = day / 6e4;
217
+ const wholeDay = [base, base + MINUTES_PER_DAY];
218
+ if (event.allDay || !event.startTime) return wholeDay;
219
+ const from = parseTimeToMinutes(event.startTime);
220
+ if (from === null) return wholeDay;
221
+ if (event.endTime === void 0 || event.endTime === null) {
222
+ return [base + from, base + from + slotDuration];
223
+ }
224
+ const to = parseTimeToMinutes(event.endTime);
225
+ if (to === null) return wholeDay;
226
+ return [base + from, base + (to <= from ? to + MINUTES_PER_DAY : to)];
227
+ }
228
+ function mergeIntervals(intervals) {
229
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
230
+ return sorted.reduce((merged, [start, end]) => {
231
+ const last = merged[merged.length - 1];
232
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
233
+ else merged.push([start, end]);
234
+ return merged;
235
+ }, []);
236
+ }
237
+ function bookedSlots(events, date, slotDuration = DEFAULT_SLOT_DURATION) {
238
+ const base = normalizeDate(date).getTime() / 6e4;
239
+ const previousDay = new Date(date);
240
+ previousDay.setDate(previousDay.getDate() - 1);
241
+ const intervals = [];
242
+ for (const event of new Set(events)) {
243
+ for (const day of [previousDay, date]) {
244
+ if (!eventCoversDate(event, day)) continue;
245
+ const interval = eventInterval(event, slotDuration, day);
246
+ if (interval) intervals.push(interval);
247
+ }
248
+ }
249
+ const merged = mergeIntervals(intervals);
250
+ return Array.from(
251
+ { length: Math.floor(MINUTES_PER_DAY / slotDuration) },
252
+ (_, index) => {
253
+ const start = base + index * slotDuration;
254
+ const end = start + slotDuration;
255
+ return merged.some(([from, to]) => start < to && end > from);
256
+ }
257
+ );
258
+ }
259
+ var MONTHS, MONTHS_FULL, DAYS, DEFAULT_CATEGORY_COLORS, HTML_ESCAPES, MINUTES_PER_DAY, DEFAULT_SLOT_DURATION;
177
260
  var init_utils = __esm({
178
261
  "src/core/utils.ts"() {
179
262
  "use strict";
@@ -222,6 +305,15 @@ var init_utils = __esm({
222
305
  appointment: "#f59e0b",
223
306
  other: "#6b7280"
224
307
  };
308
+ HTML_ESCAPES = {
309
+ "&": "&amp;",
310
+ "<": "&lt;",
311
+ ">": "&gt;",
312
+ '"': "&quot;",
313
+ "'": "&#39;"
314
+ };
315
+ MINUTES_PER_DAY = 1440;
316
+ DEFAULT_SLOT_DURATION = 60;
225
317
  }
226
318
  });
227
319
 
@@ -269,24 +361,38 @@ var init_calendar_engine = __esm({
269
361
  * Get view model with computed properties
270
362
  */
271
363
  getViewModel() {
272
- const calendarDates = generateCalendarDates(
273
- this.state.currentYear,
274
- this.state.currentMonth,
275
- this.config.events,
276
- this.config.weekStartsOn
364
+ const panes = Array.from(
365
+ { length: Math.max(1, this.config.monthCount ?? 1) },
366
+ (_, offset) => {
367
+ const anchor = new Date(
368
+ this.state.currentYear,
369
+ this.state.currentMonth + offset,
370
+ 1
371
+ );
372
+ const year = anchor.getFullYear();
373
+ const month = anchor.getMonth();
374
+ return {
375
+ year,
376
+ month,
377
+ monthAndYearText: getMonthYearText(year, month),
378
+ calendarDates: generateCalendarDates(
379
+ year,
380
+ month,
381
+ this.config.events,
382
+ this.config.weekStartsOn
383
+ )
384
+ };
385
+ }
277
386
  );
278
387
  return {
279
388
  ...this.state,
280
389
  months: MONTHS,
281
390
  days: DAYS,
282
391
  years: generateYears(this.config.minYear, this.config.maxYear),
283
- monthAndYearText: getMonthYearText(
284
- this.state.currentYear,
285
- this.state.currentMonth
286
- ),
392
+ monthAndYearText: panes[0].monthAndYearText,
287
393
  scheduleDay: this.state.selectedDate ? formatDateForDisplay(this.state.selectedDate) : "",
288
- calendarDates,
289
- popupPositionClass: getPopupPositionClass(this.state.selectedDayIndex)
394
+ panes,
395
+ calendarDates: panes[0].calendarDates
290
396
  };
291
397
  }
292
398
  /**
@@ -482,13 +588,13 @@ function defineCalendarElement(tagName = "kal-calendar") {
482
588
  customElements.define(tagName, CalendarElement);
483
589
  }
484
590
  }
485
- var isSameDay2, CalendarElement;
591
+ var isSameDay2, _CalendarElement, CalendarElement;
486
592
  var init_CalendarElement = __esm({
487
593
  "src/web-components/CalendarElement.ts"() {
488
594
  "use strict";
489
595
  init_core();
490
596
  isSameDay2 = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
491
- CalendarElement = class extends HTMLElement {
597
+ _CalendarElement = class _CalendarElement extends HTMLElement {
492
598
  constructor() {
493
599
  super(...arguments);
494
600
  this.engine = null;
@@ -509,6 +615,10 @@ var init_CalendarElement = __esm({
509
615
  this._categoryColors = null;
510
616
  this._renderEvent = null;
511
617
  this._renderNoEvents = null;
618
+ this._availabilityColors = null;
619
+ this._selectableStatuses = null;
620
+ this._initError = null;
621
+ this.pendingUpdate = null;
512
622
  // Selection state (availability mode — range)
513
623
  this._rangeStart = null;
514
624
  this._rangeEnd = null;
@@ -521,6 +631,7 @@ var init_CalendarElement = __esm({
521
631
  return this._events;
522
632
  }
523
633
  set events(val) {
634
+ this._initError = null;
524
635
  this._events = val;
525
636
  if (this.engine) {
526
637
  this.engine.updateEvents(val);
@@ -536,13 +647,28 @@ var init_CalendarElement = __esm({
536
647
  this.engine.updateCategoryColors(val);
537
648
  }
538
649
  }
650
+ get availabilityColors() {
651
+ return this._availabilityColors ?? {};
652
+ }
653
+ set availabilityColors(val) {
654
+ this._initError = null;
655
+ this._availabilityColors = val;
656
+ if (this.engine) this.scheduleRender();
657
+ }
658
+ get selectableStatuses() {
659
+ return this._selectableStatuses ?? [];
660
+ }
661
+ set selectableStatuses(val) {
662
+ this._selectableStatuses = val;
663
+ if (this.engine) this.scheduleRender();
664
+ }
539
665
  set renderEvent(val) {
540
666
  this._renderEvent = val;
541
- if (this.engine) this.render();
667
+ if (this.engine) this.scheduleRender();
542
668
  }
543
669
  set renderNoEvents(val) {
544
670
  this._renderNoEvents = val;
545
- if (this.engine) this.render();
671
+ if (this.engine) this.scheduleRender();
546
672
  }
547
673
  get loading() {
548
674
  return this.hasAttribute("loading");
@@ -551,10 +677,22 @@ var init_CalendarElement = __esm({
551
677
  if (val) this.setAttribute("loading", "");
552
678
  else this.removeAttribute("loading");
553
679
  }
680
+ // Custom element reactions report rather than propagate, so a failure here
681
+ // is kept and resurfaced at the next call the integrator makes
682
+ reaction(run) {
683
+ try {
684
+ run();
685
+ } catch (error) {
686
+ this._initError = error;
687
+ throw error;
688
+ }
689
+ }
554
690
  connectedCallback() {
555
691
  this.classList.add("kalendly-calendar");
556
- this.initEngine();
557
- this.render();
692
+ this.reaction(() => {
693
+ this.initEngine();
694
+ this.render();
695
+ });
558
696
  }
559
697
  disconnectedCallback() {
560
698
  this.cleanup();
@@ -564,7 +702,7 @@ var init_CalendarElement = __esm({
564
702
  attributeChangedCallback(name, oldVal, newVal) {
565
703
  if (oldVal === newVal) return;
566
704
  if (name === "loading") {
567
- this.render();
705
+ this.scheduleRender();
568
706
  return;
569
707
  }
570
708
  if (name === "selectable" && newVal === null) {
@@ -577,6 +715,46 @@ var init_CalendarElement = __esm({
577
715
  }
578
716
  this.reinit();
579
717
  }
718
+ get headingText() {
719
+ const heading = this.getAttribute("heading");
720
+ if (heading !== null) return heading;
721
+ const title = this.getAttribute("title");
722
+ if (title !== null && !_CalendarElement.titleDeprecationWarned) {
723
+ _CalendarElement.titleDeprecationWarned = true;
724
+ console.warn(
725
+ `<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.`
726
+ );
727
+ }
728
+ return title;
729
+ }
730
+ get slotDuration() {
731
+ const raw = this.getAttribute("slot-duration");
732
+ if (raw === null) return DEFAULT_SLOT_DURATION;
733
+ const minutes = Number(raw);
734
+ if (Number.isInteger(minutes) && minutes > 0 && MINUTES_PER_DAY % minutes === 0) {
735
+ return minutes;
736
+ }
737
+ console.warn(
738
+ `<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}.`
739
+ );
740
+ return DEFAULT_SLOT_DURATION;
741
+ }
742
+ warnMissingEndTime(events) {
743
+ for (const event of events) {
744
+ if (!event.startTime || event.endTime) continue;
745
+ const key = String(event.id);
746
+ if (_CalendarElement.warnedMissingEnd.has(key)) continue;
747
+ _CalendarElement.warnedMissingEnd.add(key);
748
+ console.warn(
749
+ `<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.`
750
+ );
751
+ }
752
+ }
753
+ get monthCount() {
754
+ const requested = Number(this.getAttribute("months") ?? 1);
755
+ if (!Number.isInteger(requested) || requested < 1) return 1;
756
+ return Math.min(requested, 2);
757
+ }
580
758
  get minYear() {
581
759
  const val = this.getAttribute("min-year");
582
760
  return val ? parseInt(val, 10) : (/* @__PURE__ */ new Date()).getFullYear() - 30;
@@ -596,19 +774,20 @@ var init_CalendarElement = __esm({
596
774
  minYear: this.minYear,
597
775
  maxYear: this.maxYear,
598
776
  weekStartsOn,
599
- categoryColors: this._categoryColors ?? void 0
777
+ categoryColors: this._categoryColors ?? void 0,
778
+ monthCount: this.monthCount
600
779
  });
601
780
  this.actions = this.engine.getActions();
602
781
  this.applyTheme();
603
782
  this.unsubscribe = this.engine.subscribe(() => {
604
- this.render();
783
+ this.scheduleRender();
605
784
  });
606
785
  }
607
786
  reinit() {
608
787
  if (!this.engine) return;
609
788
  this.cleanup();
610
789
  this.initEngine();
611
- this.render();
790
+ this.scheduleRender();
612
791
  }
613
792
  cleanup() {
614
793
  if (this.unsubscribe) {
@@ -645,42 +824,84 @@ var init_CalendarElement = __esm({
645
824
  applyTheme() {
646
825
  if (!this._theme) return;
647
826
  const root = document.documentElement;
648
- const t = this._theme;
649
- if (t.primary)
650
- root.style.setProperty("--calendar-primary-color", t.primary);
651
- if (t.secondary)
652
- root.style.setProperty("--calendar-secondary-color", t.secondary);
653
- if (t.tertiary)
654
- root.style.setProperty("--calendar-tertiary-color", t.tertiary);
655
- if (t.textColor)
656
- root.style.setProperty("--calendar-text-color", t.textColor);
657
- if (t.textLight)
658
- root.style.setProperty("--calendar-text-light", t.textLight);
659
- if (t.background)
660
- root.style.setProperty("--calendar-background", t.background);
661
- if (t.cellHover)
662
- root.style.setProperty("--calendar-cell-hover", t.cellHover);
663
- if (t.borderColor)
664
- root.style.setProperty("--calendar-border-color", t.borderColor);
665
- if (t.todayOutline)
666
- root.style.setProperty("--calendar-today-outline", t.todayOutline);
667
- if (t.selectedBg)
668
- root.style.setProperty("--calendar-selected-bg", t.selectedBg);
669
- if (t.headerBg) root.style.setProperty("--calendar-header-bg", t.headerBg);
670
- if (t.popupBg) root.style.setProperty("--calendar-popup-bg", t.popupBg);
671
- if (t.eventIndicator)
672
- root.style.setProperty("--calendar-event-indicator", t.eventIndicator);
827
+ for (const key of Object.keys(_CalendarElement.themeMap)) {
828
+ const value = this._theme[key];
829
+ if (value) root.style.setProperty(_CalendarElement.themeMap[key], value);
830
+ }
831
+ }
832
+ get knownBuckets() {
833
+ return [
834
+ ..._CalendarElement.BUILT_IN_BUCKETS,
835
+ ...Object.keys(this._availabilityColors ?? {})
836
+ ];
837
+ }
838
+ get updateComplete() {
839
+ return this.pendingUpdate ?? Promise.resolve();
840
+ }
841
+ scheduleRender() {
842
+ if (this.pendingUpdate) return;
843
+ this.pendingUpdate = Promise.resolve().then(() => {
844
+ this.pendingUpdate = null;
845
+ try {
846
+ this.render();
847
+ } catch (error) {
848
+ this._initError = error;
849
+ throw error;
850
+ }
851
+ });
852
+ }
853
+ rethrowInitError() {
854
+ if (this._initError) throw this._initError;
855
+ }
856
+ assertStatusesDeclared() {
857
+ if (!this.getAttribute("availability-mode")) return;
858
+ const missing = this._events.filter(
859
+ (event) => typeof event.availabilityStatus !== "string" || event.availabilityStatus === ""
860
+ ).map((event) => String(event.id));
861
+ if (missing.length) {
862
+ throw new Error(
863
+ `<kal-calendar> availability-mode requires availabilityStatus on every event. Missing on: ${missing.join(", ")}.`
864
+ );
865
+ }
866
+ }
867
+ assertStatusesKnown() {
868
+ const known = new Set(this.knownBuckets);
869
+ const unknown = this._events.filter((event) => !known.has(event.availabilityStatus)).map((event) => `${event.id} (${event.availabilityStatus})`);
870
+ if (unknown.length) {
871
+ throw new Error(
872
+ `<kal-calendar> availabilityStatus must name a built-in bucket (${_CalendarElement.BUILT_IN_BUCKETS.join(", ")}) or a key of availabilityColors. Unrecognised on: ${unknown.join(", ")}.`
873
+ );
874
+ }
875
+ }
876
+ resolveBucket(date) {
877
+ if (!this.engine) return "open";
878
+ const declared = new Set(
879
+ this.engine.getEventsForDate(date).map((event) => event.availabilityStatus)
880
+ );
881
+ if (declared.size === 0) return "open";
882
+ return this.knownBuckets.find((bucket) => declared.has(bucket));
883
+ }
884
+ isDateSelectable(date) {
885
+ if (!this.engine) return false;
886
+ if (this._selectableStatuses) {
887
+ return this._selectableStatuses.includes(this.resolveBucket(date));
888
+ }
889
+ return this.engine.getEventsForDate(date).length === 0;
673
890
  }
674
891
  render() {
675
892
  if (!this.engine) return;
676
893
  const viewModel = this.engine.getViewModel();
677
894
  const useShortMonths = this.hasAttribute("use-short-month-names");
678
- const title = this.getAttribute("title");
895
+ const title = this.headingText;
679
896
  const minYear = this.minYear;
680
897
  const maxYear = this.maxYear;
681
898
  const availabilityMode = this.getAttribute("availability-mode");
682
899
  const selectable = this.hasAttribute("selectable");
683
900
  const isLoading = this.loading;
901
+ if (availabilityMode) {
902
+ this.assertStatusesDeclared();
903
+ this.assertStatusesKnown();
904
+ }
684
905
  const today = /* @__PURE__ */ new Date();
685
906
  const todayMonth = today.getMonth();
686
907
  const todayYear = today.getFullYear();
@@ -689,7 +910,7 @@ var init_CalendarElement = __esm({
689
910
  const defaultRenderEvent = (event) => {
690
911
  const timeRange = formatTimeRange(event);
691
912
  const attendeesList = formatAttendees(event.attendees);
692
- let borderColor = event.color || "#3b82f6";
913
+ let borderColor = safeColor(event.color || "#3b82f6");
693
914
  if (event.category) {
694
915
  borderColor = this.engine.getCategoryColor(event.category);
695
916
  }
@@ -724,56 +945,56 @@ var init_CalendarElement = __esm({
724
945
  return `
725
946
  <div class="event-card" style="border-left-color: ${borderColor}">
726
947
  <div class="event-header">
727
- <div class="event-title">${event.name}</div>
948
+ <div class="event-title">${escapeHtml(event.name)}</div>
728
949
  <div class="event-badges">
729
- ${event.category ? `<span class="badge category-${event.category}">${getCategoryLabel(event.category)}</span>` : ""}
730
- ${event.priority ? `<span class="badge priority-${event.priority}">${getPriorityLabel(event.priority)}</span>` : ""}
731
- ${event.status && event.status !== "scheduled" ? `<span class="badge status-${event.status}">${getStatusLabel(event.status)}</span>` : ""}
950
+ ${event.category ? `<span class="badge category category-${slugifyToken(event.category)}">${escapeHtml(getCategoryLabel(event.category))}</span>` : ""}
951
+ ${event.priority ? `<span class="badge priority priority-${slugifyToken(event.priority)}">${escapeHtml(getPriorityLabel(event.priority))}</span>` : ""}
952
+ ${event.status && event.status !== "scheduled" ? `<span class="badge status status-${slugifyToken(event.status)}">${escapeHtml(getStatusLabel(event.status))}</span>` : ""}
732
953
  </div>
733
954
  </div>
734
955
 
735
956
  ${timeRange ? `
736
957
  <div class="event-time">
737
958
  <span class="event-time-label">Time:</span>
738
- <span class="event-time-value">${timeRange}</span>
959
+ <span class="event-time-value">${escapeHtml(timeRange)}</span>
739
960
  </div>
740
961
  ` : ""}
741
962
 
742
963
  ${event.description ? `
743
- <div class="event-description">${event.description}</div>
964
+ <div class="event-description">${escapeHtml(event.description)}</div>
744
965
  ` : ""}
745
966
 
746
967
  ${event.location ? `
747
968
  <div class="event-time">
748
969
  <span class="event-time-label">Location:</span>
749
- <span class="event-time-value">${event.location}</span>
970
+ <span class="event-time-value">${escapeHtml(event.location)}</span>
750
971
  </div>
751
972
  ` : ""}
752
973
 
753
974
  ${attendeesList ? `
754
975
  <div class="event-time">
755
976
  <span class="event-time-label">Attendees:</span>
756
- <span class="event-time-value">${attendeesList}</span>
977
+ <span class="event-time-value">${escapeHtml(attendeesList)}</span>
757
978
  </div>
758
979
  ` : ""}
759
980
 
760
981
  ${event.organizer ? `
761
982
  <div class="event-time">
762
983
  <span class="event-time-label">Organizer:</span>
763
- <span class="event-time-value">${event.organizer}</span>
984
+ <span class="event-time-value">${escapeHtml(event.organizer)}</span>
764
985
  </div>
765
986
  ` : ""}
766
987
 
767
988
  ${event.notes ? `
768
989
  <div class="event-time">
769
990
  <span class="event-time-label">Notes:</span>
770
- <span class="event-time-value">${event.notes}</span>
991
+ <span class="event-time-value">${escapeHtml(event.notes)}</span>
771
992
  </div>
772
993
  ` : ""}
773
994
 
774
995
  ${event.url ? `
775
996
  <div class="event-time">
776
- <a href="${event.url}" target="_blank" rel="noopener noreferrer" class="event-link">
997
+ <a href="${escapeHtml(safeUrl(event.url))}" target="_blank" rel="noopener noreferrer" class="event-link">
777
998
  View Details \u2192
778
999
  </a>
779
1000
  </div>
@@ -781,7 +1002,7 @@ var init_CalendarElement = __esm({
781
1002
 
782
1003
  ${event.tags && event.tags.length > 0 ? `
783
1004
  <div class="event-tags">
784
- ${event.tags.map((tag) => `<span class="event-tag">${tag}</span>`).join("")}
1005
+ ${event.tags.map((tag) => `<span class="event-tag">${escapeHtml(tag)}</span>`).join("")}
785
1006
  </div>
786
1007
  ` : ""}
787
1008
  </div>
@@ -790,97 +1011,181 @@ var init_CalendarElement = __esm({
790
1011
  const defaultRenderNoEvents = () => '<div class="no-events-message">No events scheduled for this day.</div>';
791
1012
  const renderEvent = this._renderEvent || defaultRenderEvent;
792
1013
  const renderNoEvents = this._renderNoEvents || defaultRenderNoEvents;
793
- const renderTimeGrid = (events, date) => {
794
- const isHourBooked = (hour) => events.some((event) => {
795
- if (!event.startTime || !event.endTime) return true;
796
- const [startH] = event.startTime.split(":").map(Number);
797
- const [endH] = event.endTime.split(":").map(Number);
798
- return hour >= startH && hour < endH;
799
- });
800
- const slots = Array.from({ length: 24 }, (_, hour) => {
801
- const startTime = `${String(hour).padStart(2, "0")}:00`;
802
- const endTime = `${String(hour + 1).padStart(2, "0")}:00`;
803
- const booked = isHourBooked(hour);
804
- const inTimeRange = !booked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
1014
+ const renderTimeGrid = (date) => {
1015
+ const slotDuration = this.slotDuration;
1016
+ const previousDay = new Date(date);
1017
+ previousDay.setDate(previousDay.getDate() - 1);
1018
+ const events = [
1019
+ ...this.engine.getEventsForDate(previousDay),
1020
+ ...this.engine.getEventsForDate(date)
1021
+ ];
1022
+ this.warnMissingEndTime(events);
1023
+ const booked = bookedSlots(events, date, slotDuration);
1024
+ const slots = booked.map((isBooked, index) => {
1025
+ const startTime = formatMinutes(index * slotDuration);
1026
+ const endTime = formatMinutes((index + 1) * slotDuration);
1027
+ const inTimeRange = !isBooked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
805
1028
  const isRangeStart = inTimeRange && startTime === this._timeRangeStart;
806
1029
  const isRangeEnd = inTimeRange && endTime === this._timeRangeEnd;
807
1030
  const isInRange = inTimeRange && !isRangeStart && !isRangeEnd;
808
1031
  const slotClasses = [
809
- "time-grid__slot",
810
- booked ? "time-grid__slot--booked" : "time-grid__slot--free",
811
- isRangeStart ? "time-grid__slot--range-start" : "",
812
- isRangeEnd ? "time-grid__slot--range-end" : "",
813
- isInRange ? "time-grid__slot--in-range" : ""
1032
+ "time-grid-slot",
1033
+ isBooked ? "time-grid-slot-blocked" : "time-grid-slot-open",
1034
+ isRangeStart ? "time-grid-slot-range-start" : "",
1035
+ isRangeEnd ? "time-grid-slot-range-end" : "",
1036
+ isInRange ? "time-grid-slot-in-range" : ""
814
1037
  ].filter(Boolean).join(" ");
815
- const slotAttrs = !booked && selectable ? `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}"` : "";
1038
+ const slotAttrs = `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}" data-booked="${isBooked}"`;
816
1039
  return `
817
1040
  <div class="${slotClasses}" ${slotAttrs}>
818
- <span class="time-grid__label">${startTime}</span>
819
- <span class="time-grid__status">${booked ? "Booked" : "Available"}</span>
1041
+ <span class="time-grid-label">${startTime}</span>
1042
+ <span class="time-grid-status">${isBooked ? "Booked" : "Available"}</span>
820
1043
  </div>`;
821
1044
  });
822
- return `<div class="time-grid">${slots.join("")}</div>`;
1045
+ const gridClasses = selectable ? "time-grid time-grid-selectable" : "time-grid";
1046
+ return `<div class="${gridClasses}">${slots.join("")}</div>`;
823
1047
  };
1048
+ const multiMonth = viewModel.panes.length > 1;
1049
+ const renderPane = (pane) => `
1050
+ <div class="calendar-pane">
1051
+ ${multiMonth ? `<div class="calendar-pane-caption">${escapeHtml(pane.monthAndYearText)}</div>` : ""}
1052
+ <table class="calendar-table calendar-table-bordered">
1053
+ <thead>
1054
+ <tr>
1055
+ ${viewModel.days.map((day) => `<th>${day.slice(0, 3)}</th>`).join("")}
1056
+ </tr>
1057
+ </thead>
1058
+ <tbody data-calendar-body>
1059
+ ${isLoading ? Array.from(
1060
+ { length: 6 },
1061
+ () => `<tr>${Array.from(
1062
+ { length: 7 },
1063
+ () => `<td class="calendar-skeleton" aria-hidden="true"></td>`
1064
+ ).join("")}</tr>`
1065
+ ).join("") : pane.calendarDates.map(
1066
+ (week) => `
1067
+ <tr>
1068
+ ${week.map((calendarDate, dayIndex) => {
1069
+ const classes = getCellClasses(calendarDate);
1070
+ const cellAttrs = [];
1071
+ if (viewModel.selectedDate && isSameDay2(
1072
+ calendarDate.date,
1073
+ viewModel.selectedDate
1074
+ )) {
1075
+ classes.push("calendar-cell-selected");
1076
+ }
1077
+ if (availabilityMode && calendarDate.isCurrentMonth) {
1078
+ const bucket = this.resolveBucket(
1079
+ calendarDate.date
1080
+ );
1081
+ const custom = (this._availabilityColors ?? {})[bucket];
1082
+ classes.push(
1083
+ `availability-${slugifyToken(bucket)}`
1084
+ );
1085
+ if (custom) {
1086
+ classes.push("availability-status");
1087
+ cellAttrs.push(
1088
+ `style="--availability-color: ${escapeHtml(safeColor(custom))}"`
1089
+ );
1090
+ }
1091
+ if (!this.isDateSelectable(calendarDate.date)) {
1092
+ classes.push("availability-unselectable");
1093
+ }
1094
+ cellAttrs.push(
1095
+ `aria-label="${escapeHtml(bucket)}"`
1096
+ );
1097
+ }
1098
+ if (availabilityMode === "day" && selectable && calendarDate.isCurrentMonth) {
1099
+ const d = calendarDate.date;
1100
+ if (this._rangeStart && isSameDay2(d, this._rangeStart))
1101
+ classes.push("availability-range-start");
1102
+ if (this._rangeEnd && isSameDay2(d, this._rangeEnd))
1103
+ classes.push("availability-range-end");
1104
+ if (this._rangeStart && this._rangeEnd) {
1105
+ if (d > this._rangeStart && d < this._rangeEnd)
1106
+ classes.push("availability-in-range");
1107
+ }
1108
+ }
1109
+ const dateString = calendarDate.date.toISOString();
1110
+ return `
1111
+ <td
1112
+ class="${classes.join(" ")}"
1113
+ data-date="${dateString}"
1114
+ data-day-index="${dayIndex}"
1115
+ data-clickable="true"
1116
+ ${cellAttrs.join(" ")}
1117
+ >
1118
+ ${calendarDate.date.getDate()}
1119
+ </td>
1120
+ `;
1121
+ }).join("")}
1122
+ </tr>
1123
+ `
1124
+ ).join("")}
1125
+ </tbody>
1126
+ </table>
1127
+ </div>
1128
+ `;
824
1129
  const html = `
825
1130
  ${title ? `
826
- <div class="page--title">
827
- <h1>${title}</h1>
1131
+ <div class="calendar-title">
1132
+ <h1>${escapeHtml(title)}</h1>
828
1133
  </div>
829
1134
  ` : ""}
830
1135
 
831
- <div class="calendar--content">
832
- <div class="calendar--card">
833
- <div class="calendar--nav-header">
834
- <button type="button" class="calendar--nav-arrow" data-action="previous" aria-label="Previous month">
1136
+ <div class="calendar-content">
1137
+ <div class="calendar-card">
1138
+ <div class="calendar-nav-header">
1139
+ <button type="button" class="calendar-nav-arrow" data-action="previous" aria-label="Previous month">
835
1140
  &#8249;
836
1141
  </button>
837
1142
 
838
- <div class="calendar--picker-container" data-picker-container>
1143
+ <div class="calendar-picker-container" data-picker-container>
839
1144
  <button
840
1145
  type="button"
841
- class="calendar--picker-btn"
1146
+ class="calendar-picker-btn"
842
1147
  data-action="toggle-picker"
843
1148
  aria-expanded="${this.pickerOpen ? "true" : "false"}"
844
1149
  aria-haspopup="true"
845
1150
  >
846
1151
  ${useShortMonths ? `${MONTHS[viewModel.currentMonth]} ${viewModel.currentYear}` : viewModel.monthAndYearText}
847
- <span class="calendar--picker-chevron">&#9662;</span>
1152
+ <span class="calendar-picker-chevron">&#9662;</span>
848
1153
  </button>
849
1154
 
850
1155
  ${this.pickerOpen ? `
851
- <div class="calendar--picker-dropdown">
852
- <div class="calendar--picker-year-row">
1156
+ <div class="calendar-picker-dropdown">
1157
+ <div class="calendar-picker-year-row">
853
1158
  <button
854
1159
  type="button"
855
- class="calendar--picker-year-arrow"
1160
+ class="calendar-picker-year-arrow"
856
1161
  data-action="year-prev"
857
1162
  ${viewModel.currentYear <= minYear ? "disabled" : ""}
858
1163
  aria-label="Previous year"
859
1164
  >&#8249;</button>
860
1165
  <input
861
1166
  type="text"
862
- class="calendar--picker-year-input${!this.yearInputValid ? " invalid" : ""}"
863
- value="${this.yearInput || viewModel.currentYear}"
1167
+ class="calendar-picker-year-input${!this.yearInputValid ? " invalid" : ""}"
1168
+ value="${escapeHtml(this.yearInput || viewModel.currentYear)}"
864
1169
  data-year-input
865
1170
  aria-label="Year"
866
1171
  />
867
1172
  <button
868
1173
  type="button"
869
- class="calendar--picker-year-arrow"
1174
+ class="calendar-picker-year-arrow"
870
1175
  data-action="year-next"
871
1176
  ${viewModel.currentYear >= maxYear ? "disabled" : ""}
872
1177
  aria-label="Next year"
873
1178
  >&#8250;</button>
874
1179
  </div>
875
1180
 
876
- <div class="calendar--picker-months">
1181
+ <div class="calendar-picker-months">
877
1182
  ${(useShortMonths ? MONTHS : MONTHS_FULL).map((month, index) => {
878
1183
  const isSelected = index === viewModel.currentMonth;
879
1184
  const isCurrent = index === todayMonth && viewModel.currentYear === todayYear;
880
1185
  return `
881
1186
  <button
882
1187
  type="button"
883
- class="calendar--picker-month${isSelected ? " selected" : ""}${isCurrent ? " current-month" : ""}"
1188
+ class="calendar-picker-month${isSelected ? " selected" : ""}${isCurrent ? " current-month" : ""}"
884
1189
  data-action="select-month"
885
1190
  data-month="${index}"
886
1191
  >${month}</button>
@@ -893,70 +1198,22 @@ var init_CalendarElement = __esm({
893
1198
 
894
1199
  <button
895
1200
  type="button"
896
- class="calendar--today-btn"
1201
+ class="calendar-today-btn"
897
1202
  data-action="today"
898
1203
  ${isCurrentMonth ? "disabled" : ""}
899
1204
  >Today</button>
900
1205
 
901
- <button type="button" class="calendar--nav-arrow" data-action="next" aria-label="Next month">
1206
+ <button type="button" class="calendar-nav-arrow" data-action="next" aria-label="Next month">
902
1207
  &#8250;
903
1208
  </button>
904
1209
  </div>
905
1210
 
906
- <table class="calendar--table calendar--table--bordered">
907
- <thead>
908
- <tr>
909
- ${viewModel.days.map((day) => `<th>${day.slice(0, 3)}</th>`).join("")}
910
- </tr>
911
- </thead>
912
- <tbody data-calendar-body>
913
- ${isLoading ? Array.from(
914
- { length: 6 },
915
- () => `<tr>${Array.from(
916
- { length: 7 },
917
- () => `<td class="calendar--skeleton" aria-hidden="true"></td>`
918
- ).join("")}</tr>`
919
- ).join("") : viewModel.calendarDates.map(
920
- (week) => `
921
- <tr>
922
- ${week.map((calendarDate, dayIndex) => {
923
- const classes = getCellClasses(calendarDate);
924
- if (availabilityMode && calendarDate.isCurrentMonth) {
925
- classes.push(
926
- calendarDate.hasEvents ? "availability--booked" : "availability--free"
927
- );
928
- }
929
- if (availabilityMode === "day" && selectable && calendarDate.isCurrentMonth) {
930
- const d = calendarDate.date;
931
- if (this._rangeStart && isSameDay2(d, this._rangeStart))
932
- classes.push("availability--range-start");
933
- if (this._rangeEnd && isSameDay2(d, this._rangeEnd))
934
- classes.push("availability--range-end");
935
- if (this._rangeStart && this._rangeEnd) {
936
- if (d > this._rangeStart && d < this._rangeEnd)
937
- classes.push("availability--in-range");
938
- }
939
- }
940
- const dateString = calendarDate.date.toISOString();
941
- return `
942
- <td
943
- class="${classes.join(" ")}"
944
- data-date="${dateString}"
945
- data-day-index="${dayIndex}"
946
- data-clickable="true"
947
- >
948
- ${calendarDate.date.getDate()}
949
- </td>
950
- `;
951
- }).join("")}
952
- </tr>
953
- `
954
- ).join("")}
955
- </tbody>
956
- </table>
1211
+ <div class="calendar-panes">
1212
+ ${viewModel.panes.map(renderPane).join("")}
1213
+ </div>
957
1214
 
958
1215
  ${!isLoading && availabilityMode !== "day" && viewModel.selectedDate ? `
959
- <div class="date-popup ${viewModel.popupPositionClass}">
1216
+ <div class="date-popup">
960
1217
  <div class="popup-header">
961
1218
  <h2>${viewModel.scheduleDay}</h2>
962
1219
  <button type="button" class="popup-close" data-action="close-popup" aria-label="Close">\u2715</button>
@@ -969,7 +1226,7 @@ var init_CalendarElement = __esm({
969
1226
  ` : ""}
970
1227
 
971
1228
  <div class="events-container">
972
- ${availabilityMode === "time" ? renderTimeGrid(viewModel.tasks, viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1229
+ ${availabilityMode === "time" ? renderTimeGrid(viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
973
1230
  </div>
974
1231
  </div>
975
1232
  ` : ""}
@@ -994,8 +1251,7 @@ var init_CalendarElement = __esm({
994
1251
  const availMode = this.getAttribute("availability-mode");
995
1252
  const isSelectable = this.hasAttribute("selectable");
996
1253
  if (availMode === "day" && isSelectable) {
997
- const isBooked = cell.classList.contains("availability--booked");
998
- if (!isBooked) {
1254
+ if (this.isDateSelectable(date)) {
999
1255
  let startDate;
1000
1256
  let endDate;
1001
1257
  if (this._rangeEnd !== null) {
@@ -1013,7 +1269,7 @@ var init_CalendarElement = __esm({
1013
1269
  cursor.setDate(cursor.getDate() + 1);
1014
1270
  let blocked = false;
1015
1271
  while (cursor < e2) {
1016
- if (this.engine.getEventsForDate(cursor).length > 0) {
1272
+ if (!this.isDateSelectable(cursor)) {
1017
1273
  blocked = true;
1018
1274
  break;
1019
1275
  }
@@ -1131,6 +1387,20 @@ var init_CalendarElement = __esm({
1131
1387
  const slotStart = actionEl.dataset.startTime;
1132
1388
  const slotEnd = actionEl.dataset.endTime;
1133
1389
  const slotDate = new Date(actionEl.dataset.date);
1390
+ const slotBooked = actionEl.dataset.booked === "true";
1391
+ this.dispatchEvent(
1392
+ new CustomEvent("cal-slot-select", {
1393
+ bubbles: true,
1394
+ composed: true,
1395
+ detail: {
1396
+ date: slotDate,
1397
+ startTime: slotStart,
1398
+ endTime: slotEnd,
1399
+ booked: slotBooked
1400
+ }
1401
+ })
1402
+ );
1403
+ if (slotBooked || !this.hasAttribute("selectable")) break;
1134
1404
  if (this._timeRangeComplete) {
1135
1405
  this._timeRangeDate = slotDate;
1136
1406
  this._timeRangeStart = slotStart;
@@ -1247,31 +1517,86 @@ var init_CalendarElement = __esm({
1247
1517
  this.theme = theme;
1248
1518
  }
1249
1519
  getCurrentDate() {
1520
+ this.rethrowInitError();
1250
1521
  return this.engine?.getViewModel().selectedDate ?? null;
1251
1522
  }
1252
1523
  goToDate(date) {
1524
+ this.rethrowInitError();
1253
1525
  const year = date.getFullYear();
1254
1526
  const month = date.getMonth();
1255
1527
  this.dispatchMonthChange(year, month);
1256
1528
  this.actions?.jump(year, month);
1257
1529
  }
1258
1530
  getEngine() {
1531
+ this.rethrowInitError();
1259
1532
  if (!this.engine)
1260
1533
  throw new Error("CalendarElement is not connected to the DOM");
1261
1534
  return this.engine;
1262
1535
  }
1263
1536
  };
1264
- CalendarElement.observedAttributes = [
1537
+ _CalendarElement.observedAttributes = [
1265
1538
  "initial-date",
1266
1539
  "min-year",
1267
1540
  "max-year",
1268
1541
  "week-starts-on",
1542
+ "heading",
1543
+ "months",
1544
+ "slot-duration",
1269
1545
  "title",
1270
1546
  "use-short-month-names",
1271
1547
  "availability-mode",
1272
1548
  "selectable",
1273
1549
  "loading"
1274
1550
  ];
1551
+ _CalendarElement.themeMap = {
1552
+ primary: "--calendar-primary-color",
1553
+ secondary: "--calendar-secondary-color",
1554
+ tertiary: "--calendar-tertiary-color",
1555
+ textColor: "--calendar-text-color",
1556
+ textLight: "--calendar-text-light",
1557
+ background: "--calendar-background",
1558
+ cellHover: "--calendar-cell-hover",
1559
+ borderColor: "--calendar-border-color",
1560
+ todayOutline: "--calendar-today-outline",
1561
+ selectedBg: "--calendar-selected-bg",
1562
+ headerBg: "--calendar-header-bg",
1563
+ popupBg: "--calendar-popup-bg",
1564
+ pickerBg: "--calendar-picker-bg",
1565
+ pickerShadow: "--calendar-picker-shadow",
1566
+ eventIndicator: "--calendar-event-indicator",
1567
+ onAccent: "--calendar-on-accent",
1568
+ link: "--calendar-link",
1569
+ openBg: "--calendar-open-bg",
1570
+ openFg: "--calendar-open-fg",
1571
+ conditionalBg: "--calendar-conditional-bg",
1572
+ conditionalFg: "--calendar-conditional-fg",
1573
+ blockedBg: "--calendar-blocked-bg",
1574
+ blockedFg: "--calendar-blocked-fg",
1575
+ rangeBg: "--calendar-range-bg",
1576
+ rangeOutline: "--calendar-range-outline",
1577
+ inRangeBg: "--calendar-in-range-bg",
1578
+ inRangeOutline: "--calendar-in-range-outline",
1579
+ badgeBg: "--calendar-badge-bg",
1580
+ badgeText: "--calendar-badge-text",
1581
+ badgeSuccessBg: "--calendar-badge-success-bg",
1582
+ badgeSuccessText: "--calendar-badge-success-text",
1583
+ badgeInfoBg: "--calendar-badge-info-bg",
1584
+ badgeInfoText: "--calendar-badge-info-text",
1585
+ badgeWarningBg: "--calendar-badge-warning-bg",
1586
+ badgeWarningText: "--calendar-badge-warning-text",
1587
+ badgeDangerBg: "--calendar-badge-danger-bg",
1588
+ badgeDangerText: "--calendar-badge-danger-text",
1589
+ badgeNeutralBg: "--calendar-badge-neutral-bg",
1590
+ badgeNeutralText: "--calendar-badge-neutral-text",
1591
+ badgePositiveBg: "--calendar-badge-positive-bg",
1592
+ badgePositiveText: "--calendar-badge-positive-text",
1593
+ badgeTentativeBg: "--calendar-badge-tentative-bg",
1594
+ badgeTentativeText: "--calendar-badge-tentative-text"
1595
+ };
1596
+ _CalendarElement.warnedMissingEnd = /* @__PURE__ */ new Set();
1597
+ _CalendarElement.BUILT_IN_BUCKETS = ["blocked", "conditional", "open"];
1598
+ _CalendarElement.titleDeprecationWarned = false;
1599
+ CalendarElement = _CalendarElement;
1275
1600
  }
1276
1601
  });
1277
1602
 
@@ -1290,11 +1615,18 @@ export {
1290
1615
  CalendarEngine,
1291
1616
  DAYS,
1292
1617
  DEFAULT_CATEGORY_COLORS,
1618
+ DEFAULT_SLOT_DURATION,
1619
+ MINUTES_PER_DAY,
1293
1620
  MONTHS,
1294
1621
  MONTHS_FULL,
1622
+ bookedSlots,
1295
1623
  defineCalendarElement,
1624
+ escapeHtml,
1625
+ eventCoversDate,
1626
+ eventInterval,
1296
1627
  formatAttendees,
1297
1628
  formatDateForDisplay,
1629
+ formatMinutes,
1298
1630
  formatTimeRange,
1299
1631
  generateCalendarDates,
1300
1632
  generateYears,
@@ -1303,13 +1635,17 @@ export {
1303
1635
  getDefaultEventColor,
1304
1636
  getEventsForDate,
1305
1637
  getMonthYearText,
1306
- getPopupPositionClass,
1307
1638
  hasEvents,
1308
1639
  isSameDay,
1309
1640
  isToday,
1310
1641
  isValidHexColor,
1311
1642
  mergeCategoryColors,
1643
+ mergeIntervals,
1312
1644
  normalizeDate,
1645
+ parseTimeToMinutes,
1646
+ safeColor,
1647
+ safeUrl,
1648
+ slugifyToken,
1313
1649
  sortEventsByTime
1314
1650
  };
1315
1651
  //# sourceMappingURL=index.mjs.map