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