@dodlhuat/basix 1.2.8 → 1.2.9

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 DELETED
@@ -1,774 +0,0 @@
1
- // ============================================================
2
- // calendar.ts — Basix Calendar Component
3
- // ============================================================
4
-
5
- export interface CalendarEvent {
6
- id: string;
7
- title: string;
8
- start: Date;
9
- end: Date;
10
- allDay?: boolean;
11
- className?: string;
12
- }
13
-
14
- export type CalendarView = 'month' | 'week' | 'agenda';
15
-
16
- export interface CalendarLocale {
17
- monthNames: string[];
18
- dayNamesShort: string[];
19
- dayNamesFull: string[];
20
- firstDayOfWeek: number;
21
- today: string;
22
- month: string;
23
- week: string;
24
- agenda: string;
25
- allDay: string;
26
- noEvents: string;
27
- }
28
-
29
- export interface CalendarOptions {
30
- container: HTMLElement | string;
31
- events?: CalendarEvent[];
32
- view?: CalendarView;
33
- locale?: Partial<CalendarLocale>;
34
- showOutsideDays?: boolean;
35
- onDayClick?: (date: Date) => void;
36
- onEventClick?: (event: CalendarEvent) => void;
37
- onChange?: (date: Date, view: CalendarView) => void;
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;
57
- }
58
-
59
- // -----------------------------------------------------------
60
- // Date Logic
61
- // -----------------------------------------------------------
62
-
63
- export const CalendarLogic = {
64
- getMonthGrid(year: number, month: number, firstDayOfWeek: number): Date[] {
65
- const firstOfMonth = new Date(year, month, 1);
66
- const lastOfMonth = new Date(year, month + 1, 0);
67
-
68
- let startDow = firstOfMonth.getDay() - firstDayOfWeek;
69
- if (startDow < 0) startDow += 7;
70
-
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));
74
-
75
- const remaining = 7 - (days.length % 7);
76
- if (remaining < 7) {
77
- for (let i = 1; i <= remaining; i++) days.push(new Date(year, month + 1, i));
78
- }
79
- return days;
80
- },
81
-
82
- getWeekDays(date: Date, firstDayOfWeek: number): Date[] {
83
- const d = new Date(date);
84
- let diff = d.getDay() - firstDayOfWeek;
85
- if (diff < 0) diff += 7;
86
- d.setDate(d.getDate() - diff);
87
- return Array.from({ length: 7 }, (_, i) => {
88
- const day = new Date(d);
89
- day.setDate(d.getDate() + i);
90
- return day;
91
- });
92
- },
93
-
94
- isSameDay(a: Date, b: Date): boolean {
95
- return a.getFullYear() === b.getFullYear()
96
- && a.getMonth() === b.getMonth()
97
- && a.getDate() === b.getDate();
98
- },
99
-
100
- isToday(date: Date): boolean {
101
- return CalendarLogic.isSameDay(date, new Date());
102
- },
103
-
104
- isCurrentMonth(date: Date, year: number, month: number): boolean {
105
- return date.getFullYear() === year && date.getMonth() === month;
106
- },
107
-
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
-
118
- getEventsForDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
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);
122
- },
123
-
124
- getAllDayEvents(events: CalendarEvent[], day: Date): CalendarEvent[] {
125
- return CalendarLogic.getEventsForDay(events, day).filter(e => e.allDay);
126
- },
127
-
128
- getTimedEvents(events: CalendarEvent[], day: Date): CalendarEvent[] {
129
- return CalendarLogic.getEventsForDay(events, day).filter(e => !e.allDay);
130
- },
131
-
132
- getEventPosition(event: CalendarEvent, day: Date): { top: number; height: number } {
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();
138
- return {
139
- top: (startMs / totalMs) * 100,
140
- height: Math.max(((endMs - startMs) / totalMs) * 100, 2),
141
- };
142
- },
143
-
144
- formatTime(date: Date): string {
145
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
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
- },
238
- };
239
-
240
- // -----------------------------------------------------------
241
- // Default Locale
242
- // -----------------------------------------------------------
243
-
244
- const DEFAULT_LOCALE: CalendarLocale = {
245
- monthNames: [
246
- 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
247
- 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
248
- ],
249
- dayNamesShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
250
- dayNamesFull: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
251
- firstDayOfWeek: 1,
252
- today: 'Heute',
253
- month: 'Monat',
254
- week: 'Woche',
255
- agenda: 'Agenda',
256
- allDay: 'Ganztägig',
257
- noEvents: 'Keine Termine',
258
- };
259
-
260
- // -----------------------------------------------------------
261
- // Renderer
262
- // -----------------------------------------------------------
263
-
264
- export class CalendarRenderer {
265
- private locale: CalendarLocale;
266
-
267
- constructor(locale: CalendarLocale) {
268
- this.locale = locale;
269
- }
270
-
271
- renderWeekdayHeaders(): string {
272
- const { dayNamesShort, firstDayOfWeek } = this.locale;
273
- const ordered = [
274
- ...dayNamesShort.slice(firstDayOfWeek),
275
- ...dayNamesShort.slice(0, firstDayOfWeek),
276
- ];
277
- return ordered
278
- .map(name => `<div class="cal__weekday" aria-label="${name}">${name}</div>`)
279
- .join('');
280
- }
281
-
282
- renderEvent(event: CalendarEvent, compact = false): string {
283
- const cls = event.className ?? '';
284
- if (compact) {
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>`;
286
- }
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}">
288
- <span class="cal__event-time">${CalendarLogic.formatTime(event.start)}</span>
289
- ${event.title}
290
- </div>`;
291
- }
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
-
311
- renderMonthDay(
312
- date: Date,
313
- currentMonth: number,
314
- currentYear: number,
315
- events: CalendarEvent[],
316
- showOutsideDays: boolean,
317
- ): string {
318
- const isOutside = !CalendarLogic.isCurrentMonth(date, currentYear, currentMonth);
319
-
320
- if (isOutside && !showOutsideDays) {
321
- return `<div class="cal__day cal__day--empty" aria-hidden="true"></div>`;
322
- }
323
-
324
- const allForDay = CalendarLogic.getEventsForDay(events, date);
325
- const pillEvents = allForDay.filter(e => !CalendarLogic.isMultiDay(e));
326
- const isToday = CalendarLogic.isToday(date);
327
-
328
- const classes = [
329
- 'cal__day',
330
- isToday ? 'is-today' : '',
331
- isOutside ? 'cal__day--outside' : '',
332
- allForDay.length > 0 ? 'has-events' : '',
333
- ].filter(Boolean).join(' ');
334
-
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()}">
340
- <span class="cal__day-num">${date.getDate()}</span>
341
- <div class="cal__day-events">${eventsHtml}${moreHtml}</div>
342
- </div>`;
343
- }
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
-
366
- renderMonthView(
367
- year: number,
368
- month: number,
369
- events: CalendarEvent[],
370
- showOutsideDays: boolean,
371
- firstDayOfWeek: number,
372
- ): string {
373
- const days = CalendarLogic.getMonthGrid(year, month, firstDayOfWeek);
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
- }
381
-
382
- return `<div class="cal__month-grid" role="grid" aria-label="${this.locale.monthNames[month]} ${year}">
383
- <div class="cal__month-head">${this.renderWeekdayHeaders()}</div>
384
- ${weekRows.join('')}
385
- </div>`;
386
- }
387
-
388
- renderWeekView(
389
- date: Date,
390
- events: CalendarEvent[],
391
- firstDayOfWeek: number,
392
- showNowLine = false,
393
- ): string {
394
- const days = CalendarLogic.getWeekDays(date, firstDayOfWeek);
395
-
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('');
403
-
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('');
411
-
412
- const hourLabels = Array.from({ length: 24 }, (_, h) => {
413
- const label = h === 0 ? '' : `${String(h).padStart(2, '0')}:00`;
414
- return `<div class="cal__time-slot">${label}</div>`;
415
- }).join('');
416
-
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}
435
- </div>`;
436
- }).join('');
437
-
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
- : '';
444
-
445
- return `<div class="cal__week" role="grid">
446
- <div class="cal__week-head">
447
- <div class="cal__week-head-time"></div>
448
- ${headCols}
449
- </div>
450
- <div class="cal__allday">
451
- <div class="cal__allday-label">${this.locale.allDay}</div>
452
- <div class="cal__allday-spans" style="--allday-lanes:${allDayLanes}">
453
- ${allDayCols}${allDayBars}
454
- </div>
455
- </div>
456
- <div class="cal__week-body">
457
- <div class="cal__week-grid">
458
- <div class="cal__time-col">${hourLabels}</div>
459
- ${dayCols}
460
- ${nowLine}
461
- </div>
462
- </div>
463
- </div>`;
464
- }
465
-
466
- renderAgendaView(year: number, month: number, events: CalendarEvent[]): string {
467
- const daysInMonth = new Date(year, month + 1, 0).getDate();
468
- const shownMultiDay = new Set<string>();
469
- let html = '';
470
-
471
- for (let d = 1; d <= daysInMonth; d++) {
472
- const day = new Date(year, month, d);
473
- const dayEvents = CalendarLogic.getEventsForDay(events, day);
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;
484
-
485
- const isToday = CalendarLogic.isToday(day);
486
- const dow = this.locale.dayNamesFull[day.getDay()];
487
-
488
- html += `<div class="cal__agenda-day ${isToday ? 'is-today' : ''}">
489
- <div class="cal__agenda-date">
490
- <span class="cal__agenda-dow">${dow}</span>
491
- <span class="cal__agenda-num ${isToday ? 'is-today' : ''}">${d}</span>
492
- </div>
493
- <div class="cal__agenda-events">
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>
507
- <span class="cal__agenda-event-title">${e.title}</span>
508
- </div>`;
509
- }).join('')}
510
- </div>
511
- </div>`;
512
- }
513
-
514
- if (!html) html = `<div class="cal__agenda-empty">${this.locale.noEvents}</div>`;
515
- return `<div class="cal__agenda">${html}</div>`;
516
- }
517
- }
518
-
519
- // -----------------------------------------------------------
520
- // Calendar — main controller
521
- // -----------------------------------------------------------
522
-
523
- export class Calendar {
524
- private container: HTMLElement;
525
- private options: Required<CalendarOptions>;
526
- private locale: CalendarLocale;
527
- private renderer: CalendarRenderer;
528
- private currentDate: Date;
529
- private currentView: CalendarView;
530
- private events: CalendarEvent[] = [];
531
- private nowLineTimer: ReturnType<typeof setInterval> | null = null;
532
-
533
- constructor(options: CalendarOptions) {
534
- if (typeof options.container === 'string') {
535
- const el = document.querySelector<HTMLElement>(options.container);
536
- if (!el) throw new Error(`Calendar: container "${options.container}" not found.`);
537
- this.container = el;
538
- } else {
539
- this.container = options.container;
540
- }
541
-
542
- this.locale = { ...DEFAULT_LOCALE, ...(options.locale ?? {}) };
543
- this.renderer = new CalendarRenderer(this.locale);
544
-
545
- this.options = {
546
- container: this.container,
547
- events: options.events ?? [],
548
- view: options.view ?? 'month',
549
- locale: options.locale ?? {},
550
- showOutsideDays: options.showOutsideDays ?? true,
551
- onDayClick: options.onDayClick ?? (() => {}),
552
- onEventClick: options.onEventClick ?? (() => {}),
553
- onChange: options.onChange ?? (() => {}),
554
- className: options.className ?? '',
555
- iconBasePath: options.iconBasePath ?? 'svg-icons/',
556
- };
557
-
558
- this.events = [...this.options.events];
559
- this.currentView = this.options.view;
560
- this.currentDate = new Date();
561
-
562
- this.render();
563
- this.attachEvents();
564
- }
565
-
566
- // ----------------------------------------------------------
567
- // Public API
568
- // ----------------------------------------------------------
569
-
570
- setView(view: CalendarView): void {
571
- this.currentView = view;
572
- this.render();
573
- this.options.onChange(this.currentDate, this.currentView);
574
- }
575
-
576
- next(): void {
577
- if (this.currentView === 'month' || this.currentView === 'agenda') {
578
- this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 1);
579
- } else {
580
- this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + 7);
581
- }
582
- this.render();
583
- this.options.onChange(this.currentDate, this.currentView);
584
- }
585
-
586
- prev(): void {
587
- if (this.currentView === 'month' || this.currentView === 'agenda') {
588
- this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, 1);
589
- } else {
590
- this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() - 7);
591
- }
592
- this.render();
593
- this.options.onChange(this.currentDate, this.currentView);
594
- }
595
-
596
- today(): void {
597
- this.currentDate = new Date();
598
- this.render();
599
- this.options.onChange(this.currentDate, this.currentView);
600
- }
601
-
602
- addEvent(event: CalendarEvent): void {
603
- this.events.push(event);
604
- this.render();
605
- }
606
-
607
- removeEvent(id: string): void {
608
- this.events = this.events.filter(e => e.id !== id);
609
- this.render();
610
- }
611
-
612
- setEvents(events: CalendarEvent[]): void {
613
- this.events = [...events];
614
- this.render();
615
- }
616
-
617
- getEvents(): CalendarEvent[] {
618
- return [...this.events];
619
- }
620
-
621
- destroy(): void {
622
- this.clearNowLineTimer();
623
- this.container.removeEventListener('click', this.boundHandleClick);
624
- this.container.removeEventListener('keydown', this.boundHandleKeydown);
625
- this.container.innerHTML = '';
626
- this.container.removeAttribute('data-cal');
627
- }
628
-
629
- // ----------------------------------------------------------
630
- // Rendering
631
- // ----------------------------------------------------------
632
-
633
- private getTitle(): string {
634
- const { monthNames } = this.locale;
635
- const y = this.currentDate.getFullYear();
636
- const m = this.currentDate.getMonth();
637
-
638
- if (this.currentView === 'week') {
639
- const days = CalendarLogic.getWeekDays(this.currentDate, this.locale.firstDayOfWeek);
640
- const first = days[0];
641
- const last = days[6];
642
- return first.getMonth() === last.getMonth()
643
- ? `${monthNames[first.getMonth()]} ${y}`
644
- : `${monthNames[first.getMonth()]} – ${monthNames[last.getMonth()]} ${y}`;
645
- }
646
- return `${monthNames[m]} ${y}`;
647
- }
648
-
649
- private buildHeader(): string {
650
- const v = this.currentView;
651
- return `<div class="cal__header">
652
- <div class="cal__nav">
653
- <button class="cal__btn cal__btn--today" data-action="today" aria-label="${this.locale.today}">${this.locale.today}</button>
654
- <button class="cal__btn" data-action="prev" aria-label="Zurück">
655
- <svg class="icon-svg" aria-hidden="true"><use href="${this.options.iconBasePath}icons.svg#chevron_left"/></svg>
656
- </button>
657
- <button class="cal__btn" data-action="next" aria-label="Vor">
658
- <svg class="icon-svg" aria-hidden="true"><use href="${this.options.iconBasePath}icons.svg#chevron_right"/></svg>
659
- </button>
660
- </div>
661
- <h2 class="cal__title" aria-live="polite">${this.getTitle()}</h2>
662
- <div class="cal__view-toggle" role="group" aria-label="Ansicht wählen">
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>
666
- </div>
667
- </div>`;
668
- }
669
-
670
- private buildBody(): string {
671
- const { firstDayOfWeek } = this.locale;
672
- const y = this.currentDate.getFullYear();
673
- const m = this.currentDate.getMonth();
674
-
675
- switch (this.currentView) {
676
- case 'month':
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
- }
683
- case 'agenda':
684
- return this.renderer.renderAgendaView(y, m, this.events);
685
- }
686
- }
687
-
688
- private render(): void {
689
- this.clearNowLineTimer();
690
- const rootClass = ['cal', this.options.className].filter(Boolean).join(' ');
691
- this.container.setAttribute('data-cal', this.currentView);
692
- this.container.innerHTML = `<div class="${rootClass}" role="application" aria-label="Kalender">
693
- ${this.buildHeader()}
694
- <div class="cal__body">${this.buildBody()}</div>
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
- }
725
- }
726
-
727
- // ----------------------------------------------------------
728
- // Event delegation
729
- // ----------------------------------------------------------
730
-
731
- private readonly boundHandleClick = (e: MouseEvent) => this.handleClick(e);
732
- private readonly boundHandleKeydown = (e: KeyboardEvent) => this.handleKeydown(e);
733
-
734
- private attachEvents(): void {
735
- this.container.addEventListener('click', this.boundHandleClick);
736
- this.container.addEventListener('keydown', this.boundHandleKeydown);
737
- }
738
-
739
- private handleClick(e: MouseEvent): void {
740
- const target = e.target as HTMLElement;
741
-
742
- const btn = target.closest<HTMLElement>('[data-action]');
743
- if (btn) {
744
- const action = btn.dataset.action!;
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');
750
- else if (action === 'view-agenda') this.setView('agenda');
751
- return;
752
- }
753
-
754
- const eventEl = target.closest<HTMLElement>('[data-event-id]');
755
- if (eventEl) {
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); }
759
- return;
760
- }
761
-
762
- }
763
-
764
- private handleKeydown(e: KeyboardEvent): void {
765
- const target = e.target as HTMLElement;
766
-
767
- if (e.key === 'Enter' || e.key === ' ') {
768
- if (target.closest('[data-event-id], [data-action]')) {
769
- e.preventDefault();
770
- target.click();
771
- }
772
- }
773
- }
774
- }