@dodlhuat/basix 1.2.3 → 1.2.5

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/js/calendar.ts CHANGED
@@ -1,19 +1,13 @@
1
1
  // ============================================================
2
2
  // calendar.ts — Basix Calendar Component
3
- // Integrates with @dodlhuat/basix design tokens & conventions
4
3
  // ============================================================
5
4
 
6
- // -----------------------------------------------------------
7
- // Types & Interfaces
8
- // -----------------------------------------------------------
9
-
10
5
  export interface CalendarEvent {
11
6
  id: string;
12
7
  title: string;
13
8
  start: Date;
14
9
  end: Date;
15
10
  allDay?: boolean;
16
- /** Extra CSS class — use Basix badge/alert classes e.g. "badge-success" */
17
11
  className?: string;
18
12
  }
19
13
 
@@ -23,7 +17,6 @@ export interface CalendarLocale {
23
17
  monthNames: string[];
24
18
  dayNamesShort: string[];
25
19
  dayNamesFull: string[];
26
- /** 0 = Sunday, 1 = Monday */
27
20
  firstDayOfWeek: number;
28
21
  today: string;
29
22
  month: string;
@@ -34,68 +27,63 @@ export interface CalendarLocale {
34
27
  }
35
28
 
36
29
  export interface CalendarOptions {
37
- /** Target container element or CSS selector */
38
30
  container: HTMLElement | string;
39
31
  events?: CalendarEvent[];
40
32
  view?: CalendarView;
41
33
  locale?: Partial<CalendarLocale>;
42
- /** Show days outside the current month in month view */
43
34
  showOutsideDays?: boolean;
44
- /** Callback when a day cell is clicked */
45
35
  onDayClick?: (date: Date) => void;
46
- /** Callback when an event is clicked */
47
36
  onEventClick?: (event: CalendarEvent) => void;
48
- /** Callback when view or date changes */
49
37
  onChange?: (date: Date, view: CalendarView) => void;
50
- /** Extra CSS class injected on the root .cal element */
51
38
  className?: string;
39
+ iconBasePath?: string;
40
+ }
41
+
42
+ interface SpanLayout {
43
+ event: CalendarEvent;
44
+ colStart: number;
45
+ colEnd: number;
46
+ lane: number;
47
+ continuesBefore: boolean;
48
+ continuesAfter: boolean;
49
+ }
50
+
51
+ interface TimedEventLayout {
52
+ event: CalendarEvent;
53
+ top: number;
54
+ height: number;
55
+ col: number;
56
+ cols: number;
52
57
  }
53
58
 
54
59
  // -----------------------------------------------------------
55
- // Date Logic (pure functions, no side effects)
60
+ // Date Logic
56
61
  // -----------------------------------------------------------
57
62
 
58
63
  export const CalendarLogic = {
59
- /**
60
- * Returns all days to render for a month grid (including leading/trailing
61
- * days from adjacent months to fill the 7-column grid).
62
- */
63
64
  getMonthGrid(year: number, month: number, firstDayOfWeek: number): Date[] {
64
65
  const firstOfMonth = new Date(year, month, 1);
65
66
  const lastOfMonth = new Date(year, month + 1, 0);
66
67
 
67
- // Leading days from previous month
68
68
  let startDow = firstOfMonth.getDay() - firstDayOfWeek;
69
69
  if (startDow < 0) startDow += 7;
70
70
 
71
71
  const days: Date[] = [];
72
+ for (let i = startDow; i > 0; i--) days.push(new Date(year, month, 1 - i));
73
+ for (let d = 1; d <= lastOfMonth.getDate(); d++) days.push(new Date(year, month, d));
72
74
 
73
- for (let i = startDow; i > 0; i--) {
74
- days.push(new Date(year, month, 1 - i));
75
- }
76
- for (let d = 1; d <= lastOfMonth.getDate(); d++) {
77
- days.push(new Date(year, month, d));
78
- }
79
-
80
- // Trailing days to fill remaining cells (always complete the row)
81
75
  const remaining = 7 - (days.length % 7);
82
76
  if (remaining < 7) {
83
- for (let i = 1; i <= remaining; i++) {
84
- days.push(new Date(year, month + 1, i));
85
- }
77
+ for (let i = 1; i <= remaining; i++) days.push(new Date(year, month + 1, i));
86
78
  }
87
-
88
79
  return days;
89
80
  },
90
81
 
91
- /** Returns the 7 dates of the week containing `date`. */
92
82
  getWeekDays(date: Date, firstDayOfWeek: number): Date[] {
93
83
  const d = new Date(date);
94
- const dow = d.getDay();
95
- let diff = dow - firstDayOfWeek;
84
+ let diff = d.getDay() - firstDayOfWeek;
96
85
  if (diff < 0) diff += 7;
97
86
  d.setDate(d.getDate() - diff);
98
-
99
87
  return Array.from({ length: 7 }, (_, i) => {
100
88
  const day = new Date(d);
101
89
  day.setDate(d.getDate() + i);
@@ -104,11 +92,9 @@ export const CalendarLogic = {
104
92
  },
105
93
 
106
94
  isSameDay(a: Date, b: Date): boolean {
107
- return (
108
- a.getFullYear() === b.getFullYear() &&
109
- a.getMonth() === b.getMonth() &&
110
- a.getDate() === b.getDate()
111
- );
95
+ return a.getFullYear() === b.getFullYear()
96
+ && a.getMonth() === b.getMonth()
97
+ && a.getDate() === b.getDate();
112
98
  },
113
99
 
114
100
  isToday(date: Date): boolean {
@@ -119,48 +105,136 @@ export const CalendarLogic = {
119
105
  return date.getFullYear() === year && date.getMonth() === month;
120
106
  },
121
107
 
122
- /** Returns all events that fall (fully or partially) on a given day. */
108
+ startOfDay(d: Date): Date {
109
+ const r = new Date(d);
110
+ r.setHours(0, 0, 0, 0);
111
+ return r;
112
+ },
113
+
114
+ isMultiDay(event: CalendarEvent): boolean {
115
+ return !CalendarLogic.isSameDay(event.start, event.end);
116
+ },
117
+
123
118
  getEventsForDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
124
- const dayStart = new Date(day);
125
- dayStart.setHours(0, 0, 0, 0);
126
- const dayEnd = new Date(day);
127
- dayEnd.setHours(23, 59, 59, 999);
128
-
129
- return events.filter(
130
- (e) => e.start <= dayEnd && e.end >= dayStart
131
- );
119
+ const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
120
+ const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999);
121
+ return events.filter(e => e.start <= dayEnd && e.end >= dayStart);
132
122
  },
133
123
 
134
- /** Returns only allDay events for a day. */
135
124
  getAllDayEvents(events: CalendarEvent[], day: Date): CalendarEvent[] {
136
- return CalendarLogic.getEventsForDay(events, day).filter((e) => e.allDay);
125
+ return CalendarLogic.getEventsForDay(events, day).filter(e => e.allDay);
137
126
  },
138
127
 
139
- /** Returns only timed events for a day. */
140
128
  getTimedEvents(events: CalendarEvent[], day: Date): CalendarEvent[] {
141
- return CalendarLogic.getEventsForDay(events, day).filter((e) => !e.allDay);
129
+ return CalendarLogic.getEventsForDay(events, day).filter(e => !e.allDay);
142
130
  },
143
131
 
144
- /** Returns top-offset % and height % for a timed event within a day column. */
145
132
  getEventPosition(event: CalendarEvent, day: Date): { top: number; height: number } {
146
- const dayStart = new Date(day);
147
- dayStart.setHours(0, 0, 0, 0);
148
- const dayEnd = new Date(day);
149
- dayEnd.setHours(24, 0, 0, 0);
150
-
151
- const totalMs = 24 * 60 * 60 * 1000;
152
- const startMs = Math.max(event.start.getTime(), dayStart.getTime()) - dayStart.getTime();
153
- const endMs = Math.min(event.end.getTime(), dayEnd.getTime()) - dayStart.getTime();
154
-
133
+ const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
134
+ const dayEnd = new Date(day); dayEnd.setHours(24, 0, 0, 0);
135
+ const totalMs = 24 * 60 * 60 * 1000;
136
+ const startMs = Math.max(event.start.getTime(), dayStart.getTime()) - dayStart.getTime();
137
+ const endMs = Math.min(event.end.getTime(), dayEnd.getTime()) - dayStart.getTime();
155
138
  return {
156
- top: (startMs / totalMs) * 100,
157
- height: Math.max(((endMs - startMs) / totalMs) * 100, 2), // min 2%
139
+ top: (startMs / totalMs) * 100,
140
+ height: Math.max(((endMs - startMs) / totalMs) * 100, 2),
158
141
  };
159
142
  },
160
143
 
161
144
  formatTime(date: Date): string {
162
145
  return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
163
146
  },
147
+
148
+ /** Compute horizontal span layout for a set of events within a 7-day row. */
149
+ computeSpanLayout(weekDays: Date[], events: CalendarEvent[]): SpanLayout[] {
150
+ if (!events.length) return [];
151
+
152
+ const weekStart = CalendarLogic.startOfDay(weekDays[0]);
153
+ const weekEnd = CalendarLogic.startOfDay(weekDays[6]);
154
+
155
+ const relevant = events.filter(e => {
156
+ const s = CalendarLogic.startOfDay(e.start);
157
+ const en = CalendarLogic.startOfDay(e.end);
158
+ return s <= weekEnd && en >= weekStart;
159
+ });
160
+
161
+ relevant.sort((a, b) => {
162
+ const diff = a.start.getTime() - b.start.getTime();
163
+ if (diff !== 0) return diff;
164
+ return (b.end.getTime() - b.start.getTime()) - (a.end.getTime() - a.start.getTime());
165
+ });
166
+
167
+ const laneEnds: number[] = [];
168
+ const layouts: SpanLayout[] = [];
169
+
170
+ for (const event of relevant) {
171
+ const eStart = CalendarLogic.startOfDay(event.start);
172
+ const eEnd = CalendarLogic.startOfDay(event.end);
173
+
174
+ const continuesBefore = eStart < weekStart;
175
+ const continuesAfter = eEnd > weekEnd;
176
+
177
+ let colStart = 0;
178
+ if (!continuesBefore) {
179
+ for (let i = 0; i < 7; i++) {
180
+ if (CalendarLogic.isSameDay(weekDays[i], eStart)) { colStart = i; break; }
181
+ }
182
+ }
183
+
184
+ let colEnd = 6;
185
+ if (!continuesAfter) {
186
+ for (let i = 6; i >= 0; i--) {
187
+ if (CalendarLogic.isSameDay(weekDays[i], eEnd)) { colEnd = i; break; }
188
+ }
189
+ }
190
+
191
+ let lane = 0;
192
+ while (lane < laneEnds.length && laneEnds[lane] >= colStart) lane++;
193
+ if (lane >= laneEnds.length) laneEnds.push(colEnd);
194
+ else laneEnds[lane] = colEnd;
195
+
196
+ layouts.push({ event, colStart, colEnd, lane, continuesBefore, continuesAfter });
197
+ }
198
+
199
+ return layouts;
200
+ },
201
+
202
+ /** Compute side-by-side column layout for overlapping timed events in a day column. */
203
+ computeTimedLayout(events: CalendarEvent[], day: Date): TimedEventLayout[] {
204
+ if (!events.length) return [];
205
+
206
+ const sorted = [...events].sort((a, b) => {
207
+ const diff = a.start.getTime() - b.start.getTime();
208
+ if (diff !== 0) return diff;
209
+ return (b.end.getTime() - b.start.getTime()) - (a.end.getTime() - a.start.getTime());
210
+ });
211
+
212
+ // Greedy sub-column assignment
213
+ const colEnds: Date[] = [];
214
+ const assigns: { event: CalendarEvent; col: number }[] = [];
215
+
216
+ for (const event of sorted) {
217
+ let col = 0;
218
+ while (col < colEnds.length && colEnds[col] > event.start) col++;
219
+ if (col >= colEnds.length) colEnds.push(event.end);
220
+ else colEnds[col] = event.end;
221
+ assigns.push({ event, col });
222
+ }
223
+
224
+ return assigns.map(({ event, col }) => {
225
+ // cols = highest sub-column among events that overlap this one + 1
226
+ const cols = assigns
227
+ .filter(a => a.event.start < event.end && a.event.end > event.start)
228
+ .reduce((max, a) => Math.max(max, a.col), 0) + 1;
229
+ const pos = CalendarLogic.getEventPosition(event, day);
230
+ return { event, top: pos.top, height: pos.height, col, cols };
231
+ });
232
+ },
233
+
234
+ nowLinePct(): number {
235
+ const now = new Date();
236
+ return (now.getHours() * 60 + now.getMinutes()) / 1440 * 100;
237
+ },
164
238
  };
165
239
 
166
240
  // -----------------------------------------------------------
@@ -172,19 +246,19 @@ const DEFAULT_LOCALE: CalendarLocale = {
172
246
  'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
173
247
  'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
174
248
  ],
175
- dayNamesShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
176
- dayNamesFull: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
249
+ dayNamesShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
250
+ dayNamesFull: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
177
251
  firstDayOfWeek: 1,
178
- today: 'Heute',
179
- month: 'Monat',
180
- week: 'Woche',
181
- agenda: 'Agenda',
182
- allDay: 'Ganztägig',
252
+ today: 'Heute',
253
+ month: 'Monat',
254
+ week: 'Woche',
255
+ agenda: 'Agenda',
256
+ allDay: 'Ganztägig',
183
257
  noEvents: 'Keine Termine',
184
258
  };
185
259
 
186
260
  // -----------------------------------------------------------
187
- // Renderer — builds DOM from CalendarLogic output
261
+ // Renderer
188
262
  // -----------------------------------------------------------
189
263
 
190
264
  export class CalendarRenderer {
@@ -194,7 +268,6 @@ export class CalendarRenderer {
194
268
  this.locale = locale;
195
269
  }
196
270
 
197
- /** Ordered day-name headers respecting firstDayOfWeek */
198
271
  renderWeekdayHeaders(): string {
199
272
  const { dayNamesShort, firstDayOfWeek } = this.locale;
200
273
  const ordered = [
@@ -202,168 +275,172 @@ export class CalendarRenderer {
202
275
  ...dayNamesShort.slice(0, firstDayOfWeek),
203
276
  ];
204
277
  return ordered
205
- .map((name) => `<div class="cal__weekday" aria-label="${name}">${name}</div>`)
278
+ .map(name => `<div class="cal__weekday" aria-label="${name}">${name}</div>`)
206
279
  .join('');
207
280
  }
208
281
 
209
282
  renderEvent(event: CalendarEvent, compact = false): string {
210
- const extraClass = event.className ?? '';
283
+ const cls = event.className ?? '';
211
284
  if (compact) {
212
- return `<div class="cal__event-pill ${extraClass}"
213
- data-event-id="${event.id}"
214
- role="button"
215
- tabindex="0"
216
- aria-label="${event.title}"
217
- title="${event.title}">${event.title}</div>`;
285
+ return `<div class="cal__event-pill ${cls}" data-event-id="${event.id}" role="button" tabindex="0" aria-label="${event.title}" title="${event.title}">${event.title}</div>`;
218
286
  }
219
- return `<div class="cal__event-pill ${extraClass}"
220
- data-event-id="${event.id}"
221
- role="button"
222
- tabindex="0"
223
- aria-label="${event.title}, ${CalendarLogic.formatTime(event.start)} – ${CalendarLogic.formatTime(event.end)}"
224
- title="${event.title}">
287
+ return `<div class="cal__event-pill ${cls}" data-event-id="${event.id}" role="button" tabindex="0" aria-label="${event.title}, ${CalendarLogic.formatTime(event.start)} – ${CalendarLogic.formatTime(event.end)}" title="${event.title}">
225
288
  <span class="cal__event-time">${CalendarLogic.formatTime(event.start)}</span>
226
289
  ${event.title}
227
290
  </div>`;
228
291
  }
229
292
 
293
+ renderSpanBar(layout: SpanLayout): string {
294
+ const { event, colStart, colEnd, lane, continuesBefore, continuesAfter } = layout;
295
+ const colSpan = colEnd - colStart + 1;
296
+ const cls = [
297
+ 'cal__span-bar',
298
+ event.className ?? '',
299
+ continuesBefore ? 'cal__span-bar--cont-before' : '',
300
+ continuesAfter ? 'cal__span-bar--cont-after' : '',
301
+ ].filter(Boolean).join(' ');
302
+
303
+ return `<div class="${cls}"
304
+ style="--span-col:${colStart};--span-len:${colSpan};--span-lane:${lane}"
305
+ data-event-id="${event.id}"
306
+ role="button" tabindex="0"
307
+ aria-label="${event.title}"
308
+ title="${event.title}">${event.title}</div>`;
309
+ }
310
+
230
311
  renderMonthDay(
231
312
  date: Date,
232
313
  currentMonth: number,
233
314
  currentYear: number,
234
315
  events: CalendarEvent[],
235
- selectedDate: Date | null,
236
- showOutsideDays: boolean
316
+ showOutsideDays: boolean,
237
317
  ): string {
238
- const dayEvents = CalendarLogic.getEventsForDay(events, date);
239
- const isToday = CalendarLogic.isToday(date);
240
- const isSelected = selectedDate ? CalendarLogic.isSameDay(date, selectedDate) : false;
241
318
  const isOutside = !CalendarLogic.isCurrentMonth(date, currentYear, currentMonth);
242
319
 
243
320
  if (isOutside && !showOutsideDays) {
244
321
  return `<div class="cal__day cal__day--empty" aria-hidden="true"></div>`;
245
322
  }
246
323
 
324
+ const allForDay = CalendarLogic.getEventsForDay(events, date);
325
+ const pillEvents = allForDay.filter(e => !CalendarLogic.isMultiDay(e));
326
+ const isToday = CalendarLogic.isToday(date);
327
+
247
328
  const classes = [
248
329
  'cal__day',
249
- isToday ? 'is-today' : '',
250
- isSelected ? 'is-selected' : '',
330
+ isToday ? 'is-today' : '',
251
331
  isOutside ? 'cal__day--outside' : '',
252
- dayEvents.length > 0 ? 'has-events' : '',
253
- ]
254
- .filter(Boolean)
255
- .join(' ');
256
-
257
- const eventsHtml = dayEvents
258
- .slice(0, 3)
259
- .map((e) => this.renderEvent(e, true))
260
- .join('');
332
+ allForDay.length > 0 ? 'has-events' : '',
333
+ ].filter(Boolean).join(' ');
261
334
 
262
- const moreCount = dayEvents.length - 3;
263
- const moreHtml =
264
- moreCount > 0
265
- ? `<div class="cal__event-more">+${moreCount}</div>`
266
- : '';
267
-
268
- return `<div class="${classes}"
269
- role="gridcell"
270
- tabindex="0"
271
- aria-label="${date.toLocaleDateString()}"
272
- aria-selected="${isSelected}"
273
- data-date="${date.toISOString()}">
335
+ const eventsHtml = pillEvents.slice(0, 3).map(e => this.renderEvent(e, true)).join('');
336
+ const moreCount = pillEvents.length - 3;
337
+ const moreHtml = moreCount > 0 ? `<div class="cal__event-more">+${moreCount}</div>` : '';
338
+
339
+ return `<div class="${classes}" aria-label="${date.toLocaleDateString()}">
274
340
  <span class="cal__day-num">${date.getDate()}</span>
275
341
  <div class="cal__day-events">${eventsHtml}${moreHtml}</div>
276
342
  </div>`;
277
343
  }
278
344
 
345
+ renderWeekRow(
346
+ weekDays: Date[],
347
+ currentMonth: number,
348
+ currentYear: number,
349
+ events: CalendarEvent[],
350
+ showOutsideDays: boolean,
351
+ ): string {
352
+ const multiDay = events.filter(e => CalendarLogic.isMultiDay(e));
353
+ const spans = CalendarLogic.computeSpanLayout(weekDays, multiDay);
354
+ const maxLanes = spans.length > 0 ? Math.max(...spans.map(s => s.lane)) + 1 : 0;
355
+
356
+ const dayCells = weekDays
357
+ .map(d => this.renderMonthDay(d, currentMonth, currentYear, events, showOutsideDays))
358
+ .join('');
359
+ const spanBars = spans.map(s => this.renderSpanBar(s)).join('');
360
+
361
+ return `<div class="cal__week-row" style="--span-lanes:${maxLanes}">
362
+ ${dayCells}${spanBars}
363
+ </div>`;
364
+ }
365
+
279
366
  renderMonthView(
280
367
  year: number,
281
368
  month: number,
282
369
  events: CalendarEvent[],
283
- selectedDate: Date | null,
284
370
  showOutsideDays: boolean,
285
- firstDayOfWeek: number
371
+ firstDayOfWeek: number,
286
372
  ): string {
287
373
  const days = CalendarLogic.getMonthGrid(year, month, firstDayOfWeek);
288
- const cells = days
289
- .map((d) =>
290
- this.renderMonthDay(d, month, year, events, selectedDate, showOutsideDays)
291
- )
292
- .join('');
374
+ const weekRows: string[] = [];
375
+ for (let i = 0; i < days.length; i += 7) {
376
+ weekRows.push(this.renderWeekRow(
377
+ days.slice(i, i + 7),
378
+ month, year, events, showOutsideDays,
379
+ ));
380
+ }
293
381
 
294
382
  return `<div class="cal__month-grid" role="grid" aria-label="${this.locale.monthNames[month]} ${year}">
295
- ${this.renderWeekdayHeaders()}
296
- ${cells}
383
+ <div class="cal__month-head">${this.renderWeekdayHeaders()}</div>
384
+ ${weekRows.join('')}
297
385
  </div>`;
298
386
  }
299
387
 
300
388
  renderWeekView(
301
389
  date: Date,
302
390
  events: CalendarEvent[],
303
- selectedDate: Date | null,
304
- firstDayOfWeek: number
391
+ firstDayOfWeek: number,
392
+ showNowLine = false,
305
393
  ): string {
306
394
  const days = CalendarLogic.getWeekDays(date, firstDayOfWeek);
307
395
 
308
- // All-day row
309
- const allDayCols = days
310
- .map((d) => {
311
- const adEvents = CalendarLogic.getAllDayEvents(events, d);
312
- const pills = adEvents.map((e) => this.renderEvent(e, true)).join('');
313
- return `<div class="cal__allday-col">${pills}</div>`;
314
- })
315
- .join('');
396
+ const headCols = days.map(d => {
397
+ const isToday = CalendarLogic.isToday(d);
398
+ const cls = ['cal__week-head-day', isToday ? 'is-today' : '']
399
+ .filter(Boolean).join(' ');
400
+ const dow = this.locale.dayNamesShort[(d.getDay() + 7) % 7];
401
+ return `<div class="${cls}">${dow}<span>${d.getDate()}</span></div>`;
402
+ }).join('');
316
403
 
317
- // Day column headers
318
- const headCols = days
319
- .map((d) => {
320
- const isToday = CalendarLogic.isToday(d);
321
- const isSelected = selectedDate ? CalendarLogic.isSameDay(d, selectedDate) : false;
322
- const classes = [
323
- 'cal__week-head-day',
324
- isToday ? 'is-today' : '',
325
- isSelected ? 'is-selected' : '',
326
- ]
327
- .filter(Boolean)
328
- .join(' ');
329
-
330
- const dow = this.locale.dayNamesShort[(d.getDay() + 7) % 7];
331
- return `<div class="${classes}" data-date="${d.toISOString()}">
332
- ${dow}<span>${d.getDate()}</span>
333
- </div>`;
334
- })
335
- .join('');
404
+ // All-day row: span layout for all allDay events (both single-day and multi-day)
405
+ const allDayEvents = events.filter(e => e.allDay);
406
+ const allDayLayouts = CalendarLogic.computeSpanLayout(days, allDayEvents);
407
+ const allDayLanes = allDayLayouts.length > 0 ? Math.max(...allDayLayouts.map(l => l.lane)) + 1 : 0;
408
+
409
+ const allDayCols = days.map(() => `<div class="cal__allday-col"></div>`).join('');
410
+ const allDayBars = allDayLayouts.map(l => this.renderSpanBar(l)).join('');
336
411
 
337
- // Hour slots + events
338
412
  const hourLabels = Array.from({ length: 24 }, (_, h) => {
339
413
  const label = h === 0 ? '' : `${String(h).padStart(2, '0')}:00`;
340
414
  return `<div class="cal__time-slot">${label}</div>`;
341
415
  }).join('');
342
416
 
343
- const dayCols = days
344
- .map((d) => {
345
- const timedEvents = CalendarLogic.getTimedEvents(events, d);
346
- const hourCells = Array.from({ length: 24 }, () => `<div class="cal__day-col-hour"></div>`).join('');
347
-
348
- const eventOverlays = timedEvents
349
- .map((e) => {
350
- const { top, height } = CalendarLogic.getEventPosition(e, d);
351
- const extraClass = e.className ?? '';
352
- return `<div class="cal__week-event ${extraClass}"
353
- style="top:${top.toFixed(2)}%;height:${height.toFixed(2)}%"
354
- data-event-id="${e.id}"
355
- role="button"
356
- tabindex="0"
357
- aria-label="${e.title}">
358
- <span class="cal__event-time">${CalendarLogic.formatTime(e.start)}</span>
359
- ${e.title}
417
+ const dayCols = days.map(d => {
418
+ const timedEvents = CalendarLogic.getTimedEvents(events, d);
419
+ const hourCells = Array.from({ length: 24 }, () => `<div class="cal__day-col-hour"></div>`).join('');
420
+ const layouts = CalendarLogic.computeTimedLayout(timedEvents, d);
421
+
422
+ const eventOverlays = layouts.map(({ event, top, height, col, cols }) => {
423
+ const cls = event.className ?? '';
424
+ let posStyle = `top:${top.toFixed(2)}%;height:${height.toFixed(2)}%`;
425
+ if (cols > 1) {
426
+ const l = (col / cols * 100).toFixed(2);
427
+ const w = (100 / cols).toFixed(2);
428
+ posStyle += `;left:calc(${l}% + 2px);right:auto;width:calc(${w}% - 4px)`;
429
+ }
430
+ return `<div class="cal__week-event ${cls}"
431
+ style="${posStyle}"
432
+ data-event-id="${event.id}" role="button" tabindex="0" aria-label="${event.title}">
433
+ <span class="cal__event-time">${CalendarLogic.formatTime(event.start)}</span>
434
+ ${event.title}
360
435
  </div>`;
361
- })
362
- .join('');
436
+ }).join('');
363
437
 
364
- return `<div class="cal__day-col" data-date="${d.toISOString()}">${hourCells}${eventOverlays}</div>`;
365
- })
366
- .join('');
438
+ return `<div class="cal__day-col" data-date="${d.toISOString()}">${hourCells}${eventOverlays}</div>`;
439
+ }).join('');
440
+
441
+ const nowLine = showNowLine
442
+ ? `<div class="cal__now-line" style="top:${CalendarLogic.nowLinePct().toFixed(3)}%"></div>`
443
+ : '';
367
444
 
368
445
  return `<div class="cal__week" role="grid">
369
446
  <div class="cal__week-head">
@@ -372,29 +449,41 @@ export class CalendarRenderer {
372
449
  </div>
373
450
  <div class="cal__allday">
374
451
  <div class="cal__allday-label">${this.locale.allDay}</div>
375
- ${allDayCols}
452
+ <div class="cal__allday-spans" style="--allday-lanes:${allDayLanes}">
453
+ ${allDayCols}${allDayBars}
454
+ </div>
376
455
  </div>
377
456
  <div class="cal__week-body">
378
457
  <div class="cal__week-grid">
379
458
  <div class="cal__time-col">${hourLabels}</div>
380
459
  ${dayCols}
460
+ ${nowLine}
381
461
  </div>
382
462
  </div>
383
463
  </div>`;
384
464
  }
385
465
 
386
466
  renderAgendaView(year: number, month: number, events: CalendarEvent[]): string {
387
- // Collect all days in this month that have events
388
- const daysInMonth = new Date(year, month + 1, 0).getDate();
467
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
468
+ const shownMultiDay = new Set<string>();
389
469
  let html = '';
390
470
 
391
471
  for (let d = 1; d <= daysInMonth; d++) {
392
- const day = new Date(year, month, d);
472
+ const day = new Date(year, month, d);
393
473
  const dayEvents = CalendarLogic.getEventsForDay(events, day);
394
- if (dayEvents.length === 0) continue;
474
+
475
+ // Multi-day events show only once (first occurrence in this month)
476
+ const filtered = dayEvents.filter(e => {
477
+ if (!CalendarLogic.isMultiDay(e)) return true;
478
+ if (shownMultiDay.has(e.id)) return false;
479
+ shownMultiDay.add(e.id);
480
+ return true;
481
+ });
482
+
483
+ if (!filtered.length) continue;
395
484
 
396
485
  const isToday = CalendarLogic.isToday(day);
397
- const dow = this.locale.dayNamesFull[day.getDay()];
486
+ const dow = this.locale.dayNamesFull[day.getDay()];
398
487
 
399
488
  html += `<div class="cal__agenda-day ${isToday ? 'is-today' : ''}">
400
489
  <div class="cal__agenda-date">
@@ -402,30 +491,33 @@ export class CalendarRenderer {
402
491
  <span class="cal__agenda-num ${isToday ? 'is-today' : ''}">${d}</span>
403
492
  </div>
404
493
  <div class="cal__agenda-events">
405
- ${dayEvents.map((e) => `
406
- <div class="cal__agenda-event ${e.className ?? ''}"
407
- data-event-id="${e.id}"
408
- role="button"
409
- tabindex="0">
410
- <span class="cal__agenda-event-time">
411
- ${e.allDay ? this.locale.allDay : CalendarLogic.formatTime(e.start) + ' – ' + CalendarLogic.formatTime(e.end)}
412
- </span>
494
+ ${filtered.map(e => {
495
+ const isMulti = CalendarLogic.isMultiDay(e);
496
+ let timeLabel: string;
497
+ if (isMulti) {
498
+ timeLabel = `${e.start.toLocaleDateString()} – ${e.end.toLocaleDateString()}`;
499
+ } else if (e.allDay) {
500
+ timeLabel = this.locale.allDay;
501
+ } else {
502
+ timeLabel = `${CalendarLogic.formatTime(e.start)} – ${CalendarLogic.formatTime(e.end)}`;
503
+ }
504
+ return `<div class="cal__agenda-event ${e.className ?? ''}"
505
+ data-event-id="${e.id}" role="button" tabindex="0">
506
+ <span class="cal__agenda-event-time">${timeLabel}</span>
413
507
  <span class="cal__agenda-event-title">${e.title}</span>
414
- </div>`).join('')}
508
+ </div>`;
509
+ }).join('')}
415
510
  </div>
416
511
  </div>`;
417
512
  }
418
513
 
419
- if (!html) {
420
- html = `<div class="cal__agenda-empty">${this.locale.noEvents}</div>`;
421
- }
422
-
514
+ if (!html) html = `<div class="cal__agenda-empty">${this.locale.noEvents}</div>`;
423
515
  return `<div class="cal__agenda">${html}</div>`;
424
516
  }
425
517
  }
426
518
 
427
519
  // -----------------------------------------------------------
428
- // Calendar — main controller class
520
+ // Calendar — main controller
429
521
  // -----------------------------------------------------------
430
522
 
431
523
  export class Calendar {
@@ -433,14 +525,12 @@ export class Calendar {
433
525
  private options: Required<CalendarOptions>;
434
526
  private locale: CalendarLocale;
435
527
  private renderer: CalendarRenderer;
436
-
437
528
  private currentDate: Date;
438
529
  private currentView: CalendarView;
439
- private selectedDate: Date | null = null;
440
530
  private events: CalendarEvent[] = [];
531
+ private nowLineTimer: ReturnType<typeof setInterval> | null = null;
441
532
 
442
533
  constructor(options: CalendarOptions) {
443
- // Resolve container
444
534
  if (typeof options.container === 'string') {
445
535
  const el = document.querySelector<HTMLElement>(options.container);
446
536
  if (!el) throw new Error(`Calendar: container "${options.container}" not found.`);
@@ -449,22 +539,23 @@ export class Calendar {
449
539
  this.container = options.container;
450
540
  }
451
541
 
452
- this.locale = { ...DEFAULT_LOCALE, ...(options.locale ?? {}) };
542
+ this.locale = { ...DEFAULT_LOCALE, ...(options.locale ?? {}) };
453
543
  this.renderer = new CalendarRenderer(this.locale);
454
544
 
455
545
  this.options = {
456
- container: this.container,
457
- events: options.events ?? [],
458
- view: options.view ?? 'month',
459
- locale: options.locale ?? {},
546
+ container: this.container,
547
+ events: options.events ?? [],
548
+ view: options.view ?? 'month',
549
+ locale: options.locale ?? {},
460
550
  showOutsideDays: options.showOutsideDays ?? true,
461
- onDayClick: options.onDayClick ?? (() => {}),
462
- onEventClick: options.onEventClick ?? (() => {}),
463
- onChange: options.onChange ?? (() => {}),
464
- className: options.className ?? '',
551
+ onDayClick: options.onDayClick ?? (() => {}),
552
+ onEventClick: options.onEventClick ?? (() => {}),
553
+ onChange: options.onChange ?? (() => {}),
554
+ className: options.className ?? '',
555
+ iconBasePath: options.iconBasePath ?? 'svg-icons/',
465
556
  };
466
557
 
467
- this.events = [...this.options.events];
558
+ this.events = [...this.options.events];
468
559
  this.currentView = this.options.view;
469
560
  this.currentDate = new Date();
470
561
 
@@ -484,17 +575,9 @@ export class Calendar {
484
575
 
485
576
  next(): void {
486
577
  if (this.currentView === 'month' || this.currentView === 'agenda') {
487
- this.currentDate = new Date(
488
- this.currentDate.getFullYear(),
489
- this.currentDate.getMonth() + 1,
490
- 1
491
- );
578
+ this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 1);
492
579
  } else {
493
- this.currentDate = new Date(
494
- this.currentDate.getFullYear(),
495
- this.currentDate.getMonth(),
496
- this.currentDate.getDate() + 7
497
- );
580
+ this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + 7);
498
581
  }
499
582
  this.render();
500
583
  this.options.onChange(this.currentDate, this.currentView);
@@ -502,17 +585,9 @@ export class Calendar {
502
585
 
503
586
  prev(): void {
504
587
  if (this.currentView === 'month' || this.currentView === 'agenda') {
505
- this.currentDate = new Date(
506
- this.currentDate.getFullYear(),
507
- this.currentDate.getMonth() - 1,
508
- 1
509
- );
588
+ this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, 1);
510
589
  } else {
511
- this.currentDate = new Date(
512
- this.currentDate.getFullYear(),
513
- this.currentDate.getMonth(),
514
- this.currentDate.getDate() - 7
515
- );
590
+ this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() - 7);
516
591
  }
517
592
  this.render();
518
593
  this.options.onChange(this.currentDate, this.currentView);
@@ -530,7 +605,7 @@ export class Calendar {
530
605
  }
531
606
 
532
607
  removeEvent(id: string): void {
533
- this.events = this.events.filter((e) => e.id !== id);
608
+ this.events = this.events.filter(e => e.id !== id);
534
609
  this.render();
535
610
  }
536
611
 
@@ -544,14 +619,15 @@ export class Calendar {
544
619
  }
545
620
 
546
621
  destroy(): void {
547
- this.container.removeEventListener('click', this.boundHandleClick);
622
+ this.clearNowLineTimer();
623
+ this.container.removeEventListener('click', this.boundHandleClick);
548
624
  this.container.removeEventListener('keydown', this.boundHandleKeydown);
549
625
  this.container.innerHTML = '';
550
626
  this.container.removeAttribute('data-cal');
551
627
  }
552
628
 
553
629
  // ----------------------------------------------------------
554
- // Internal rendering
630
+ // Rendering
555
631
  // ----------------------------------------------------------
556
632
 
557
633
  private getTitle(): string {
@@ -560,38 +636,33 @@ export class Calendar {
560
636
  const m = this.currentDate.getMonth();
561
637
 
562
638
  if (this.currentView === 'week') {
563
- const days = CalendarLogic.getWeekDays(this.currentDate, this.locale.firstDayOfWeek);
639
+ const days = CalendarLogic.getWeekDays(this.currentDate, this.locale.firstDayOfWeek);
564
640
  const first = days[0];
565
- const last = days[6];
566
- if (first.getMonth() === last.getMonth()) {
567
- return `${monthNames[first.getMonth()]} ${y}`;
568
- }
569
- return `${monthNames[first.getMonth()]} – ${monthNames[last.getMonth()]} ${y}`;
641
+ const last = days[6];
642
+ return first.getMonth() === last.getMonth()
643
+ ? `${monthNames[first.getMonth()]} ${y}`
644
+ : `${monthNames[first.getMonth()]} – ${monthNames[last.getMonth()]} ${y}`;
570
645
  }
571
-
572
646
  return `${monthNames[m]} ${y}`;
573
647
  }
574
648
 
575
649
  private buildHeader(): string {
576
- const activeMonth = this.currentView === 'month' || this.currentView === 'agenda' ? 'cal__btn--active' : '';
577
- const activeWeek = this.currentView === 'week' ? 'cal__btn--active' : '';
578
- const activeAgenda = this.currentView === 'agenda' ? 'cal__btn--active' : '';
579
-
650
+ const v = this.currentView;
580
651
  return `<div class="cal__header">
581
652
  <div class="cal__nav">
582
653
  <button class="cal__btn cal__btn--today" data-action="today" aria-label="${this.locale.today}">${this.locale.today}</button>
583
654
  <button class="cal__btn" data-action="prev" aria-label="Zurück">
584
- <svg class="icon-svg" aria-hidden="true"><use href="svg-icons/icons.svg#chevron_left"/></svg>
655
+ <svg class="icon-svg" aria-hidden="true"><use href="${this.options.iconBasePath}icons.svg#chevron_left"/></svg>
585
656
  </button>
586
657
  <button class="cal__btn" data-action="next" aria-label="Vor">
587
- <svg class="icon-svg" aria-hidden="true"><use href="svg-icons/icons.svg#chevron_right"/></svg>
658
+ <svg class="icon-svg" aria-hidden="true"><use href="${this.options.iconBasePath}icons.svg#chevron_right"/></svg>
588
659
  </button>
589
660
  </div>
590
661
  <h2 class="cal__title" aria-live="polite">${this.getTitle()}</h2>
591
662
  <div class="cal__view-toggle" role="group" aria-label="Ansicht wählen">
592
- <button class="cal__btn ${activeMonth}" data-action="view-month" aria-pressed="${this.currentView === 'month'}">${this.locale.month}</button>
593
- <button class="cal__btn ${activeWeek}" data-action="view-week" aria-pressed="${this.currentView === 'week'}">${this.locale.week}</button>
594
- <button class="cal__btn ${activeAgenda}" data-action="view-agenda" aria-pressed="${this.currentView === 'agenda'}">${this.locale.agenda}</button>
663
+ <button class="cal__btn ${v === 'month' ? 'cal__btn--active' : ''}" data-action="view-month" aria-pressed="${v === 'month'}">${this.locale.month}</button>
664
+ <button class="cal__btn ${v === 'week' ? 'cal__btn--active' : ''}" data-action="view-week" aria-pressed="${v === 'week'}">${this.locale.week}</button>
665
+ <button class="cal__btn ${v === 'agenda' ? 'cal__btn--active' : ''}" data-action="view-agenda" aria-pressed="${v === 'agenda'}">${this.locale.agenda}</button>
595
666
  </div>
596
667
  </div>`;
597
668
  }
@@ -603,109 +674,101 @@ export class Calendar {
603
674
 
604
675
  switch (this.currentView) {
605
676
  case 'month':
606
- return this.renderer.renderMonthView(
607
- y, m, this.events, this.selectedDate,
608
- this.options.showOutsideDays, firstDayOfWeek
609
- );
610
- case 'week':
611
- return this.renderer.renderWeekView(
612
- this.currentDate, this.events, this.selectedDate, firstDayOfWeek
613
- );
677
+ return this.renderer.renderMonthView(y, m, this.events, this.options.showOutsideDays, firstDayOfWeek);
678
+ case 'week': {
679
+ const weekDays = CalendarLogic.getWeekDays(this.currentDate, firstDayOfWeek);
680
+ const showNowLine = weekDays.some(d => CalendarLogic.isToday(d));
681
+ return this.renderer.renderWeekView(this.currentDate, this.events, firstDayOfWeek, showNowLine);
682
+ }
614
683
  case 'agenda':
615
684
  return this.renderer.renderAgendaView(y, m, this.events);
616
685
  }
617
686
  }
618
687
 
619
688
  private render(): void {
689
+ this.clearNowLineTimer();
620
690
  const rootClass = ['cal', this.options.className].filter(Boolean).join(' ');
621
691
  this.container.setAttribute('data-cal', this.currentView);
622
692
  this.container.innerHTML = `<div class="${rootClass}" role="application" aria-label="Kalender">
623
693
  ${this.buildHeader()}
624
- <div class="cal__body">
625
- ${this.buildBody()}
626
- </div>
694
+ <div class="cal__body">${this.buildBody()}</div>
627
695
  </div>`;
696
+
697
+ if (this.currentView === 'week') {
698
+ const weekDays = CalendarLogic.getWeekDays(this.currentDate, this.locale.firstDayOfWeek);
699
+ if (weekDays.some(d => CalendarLogic.isToday(d))) {
700
+ this.scrollToNow();
701
+ this.startNowLineTimer();
702
+ }
703
+ }
704
+ }
705
+
706
+ private scrollToNow(): void {
707
+ const body = this.container.querySelector<HTMLElement>('.cal__week-body');
708
+ if (!body) return;
709
+ const pct = CalendarLogic.nowLinePct() / 100;
710
+ body.scrollTop = Math.max(0, pct * body.scrollHeight - body.clientHeight / 2);
711
+ }
712
+
713
+ private startNowLineTimer(): void {
714
+ this.nowLineTimer = setInterval(() => {
715
+ const line = this.container.querySelector<HTMLElement>('.cal__now-line');
716
+ if (line) line.style.top = `${CalendarLogic.nowLinePct().toFixed(3)}%`;
717
+ }, 60_000);
718
+ }
719
+
720
+ private clearNowLineTimer(): void {
721
+ if (this.nowLineTimer !== null) {
722
+ clearInterval(this.nowLineTimer);
723
+ this.nowLineTimer = null;
724
+ }
628
725
  }
629
726
 
630
727
  // ----------------------------------------------------------
631
728
  // Event delegation
632
729
  // ----------------------------------------------------------
633
730
 
634
- private readonly boundHandleClick = (e: MouseEvent): void => this.handleClick(e);
635
- private readonly boundHandleKeydown = (e: KeyboardEvent): void => this.handleKeydown(e);
731
+ private readonly boundHandleClick = (e: MouseEvent) => this.handleClick(e);
732
+ private readonly boundHandleKeydown = (e: KeyboardEvent) => this.handleKeydown(e);
636
733
 
637
734
  private attachEvents(): void {
638
- this.container.addEventListener('click', this.boundHandleClick);
735
+ this.container.addEventListener('click', this.boundHandleClick);
639
736
  this.container.addEventListener('keydown', this.boundHandleKeydown);
640
737
  }
641
738
 
642
739
  private handleClick(e: MouseEvent): void {
643
740
  const target = e.target as HTMLElement;
644
741
 
645
- // Nav / view buttons
646
742
  const btn = target.closest<HTMLElement>('[data-action]');
647
743
  if (btn) {
648
744
  const action = btn.dataset.action!;
649
- if (action === 'prev') this.prev();
650
- else if (action === 'next') this.next();
651
- else if (action === 'today') this.today();
652
- else if (action === 'view-month') this.setView('month');
653
- else if (action === 'view-week') this.setView('week');
745
+ if (action === 'prev') this.prev();
746
+ else if (action === 'next') this.next();
747
+ else if (action === 'today') this.today();
748
+ else if (action === 'view-month') this.setView('month');
749
+ else if (action === 'view-week') this.setView('week');
654
750
  else if (action === 'view-agenda') this.setView('agenda');
655
751
  return;
656
752
  }
657
753
 
658
- // Event click
659
754
  const eventEl = target.closest<HTMLElement>('[data-event-id]');
660
755
  if (eventEl) {
661
- const id = eventEl.dataset.eventId!;
662
- const event = this.events.find((ev) => ev.id === id);
663
- if (event) {
664
- e.stopPropagation();
665
- this.options.onEventClick(event);
666
- }
756
+ const id = eventEl.dataset.eventId!;
757
+ const event = this.events.find(ev => ev.id === id);
758
+ if (event) { e.stopPropagation(); this.options.onEventClick(event); }
667
759
  return;
668
760
  }
669
761
 
670
- // Day click
671
- const dayEl = target.closest<HTMLElement>('[data-date]');
672
- if (dayEl && dayEl.dataset.date) {
673
- const date = new Date(dayEl.dataset.date);
674
- this.selectedDate = date;
675
- this.options.onDayClick(date);
676
- // Re-render to update selection state
677
- this.render();
678
- }
679
762
  }
680
763
 
681
764
  private handleKeydown(e: KeyboardEvent): void {
682
765
  const target = e.target as HTMLElement;
683
766
 
684
- // Allow Enter/Space to trigger click on focused interactive elements
685
767
  if (e.key === 'Enter' || e.key === ' ') {
686
- if (target.closest('[data-date], [data-event-id], [data-action]')) {
768
+ if (target.closest('[data-event-id], [data-action]')) {
687
769
  e.preventDefault();
688
770
  target.click();
689
771
  }
690
772
  }
691
-
692
- // Arrow key navigation within month grid
693
- if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
694
- const dayEl = target.closest<HTMLElement>('.cal__day[data-date]');
695
- if (!dayEl) return;
696
-
697
- e.preventDefault();
698
- const all = Array.from(
699
- this.container.querySelectorAll<HTMLElement>('.cal__day[data-date]:not(.cal__day--empty)')
700
- );
701
- const idx = all.indexOf(dayEl);
702
- let next = idx;
703
-
704
- if (e.key === 'ArrowRight') next = idx + 1;
705
- else if (e.key === 'ArrowLeft') next = idx - 1;
706
- else if (e.key === 'ArrowDown') next = idx + 7;
707
- else if (e.key === 'ArrowUp') next = idx - 7;
708
-
709
- all[Math.max(0, Math.min(next, all.length - 1))]?.focus();
710
773
  }
711
- }
774
+ }