@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/README.md +1 -1
- package/css/calendar.css +928 -0
- package/css/calendar.scss +114 -63
- package/css/form.scss +224 -54
- package/css/style.css +284 -230
- package/js/calendar.js +276 -187
- package/js/calendar.ts +370 -307
- package/js/docs-nav.js +1 -0
- package/js/timepicker.js +2 -12
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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.
|
|
109
|
-
a.
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
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:
|
|
157
|
-
height: Math.max(((endMs - startMs) / totalMs) * 100, 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:
|
|
176
|
-
dayNamesFull:
|
|
249
|
+
dayNamesShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
|
|
250
|
+
dayNamesFull: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
|
|
177
251
|
firstDayOfWeek: 1,
|
|
178
|
-
today:
|
|
179
|
-
month:
|
|
180
|
-
week:
|
|
181
|
-
agenda:
|
|
182
|
-
allDay:
|
|
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
|
|
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(
|
|
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
|
|
283
|
+
const cls = event.className ?? '';
|
|
211
284
|
if (compact) {
|
|
212
|
-
return `<div class="cal__event-pill ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
250
|
-
isSelected ? 'is-selected' : '',
|
|
330
|
+
isToday ? 'is-today' : '',
|
|
251
331
|
isOutside ? 'cal__day--outside' : '',
|
|
252
|
-
|
|
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
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
${
|
|
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
|
-
|
|
304
|
-
|
|
391
|
+
firstDayOfWeek: number,
|
|
392
|
+
showNowLine = false,
|
|
305
393
|
): string {
|
|
306
394
|
const days = CalendarLogic.getWeekDays(date, firstDayOfWeek);
|
|
307
395
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
//
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
const
|
|
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
|
|
472
|
+
const day = new Date(year, month, d);
|
|
393
473
|
const dayEvents = CalendarLogic.getEventsForDay(events, day);
|
|
394
|
-
|
|
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
|
|
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
|
-
${
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
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
|
|
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
|
|
542
|
+
this.locale = { ...DEFAULT_LOCALE, ...(options.locale ?? {}) };
|
|
453
543
|
this.renderer = new CalendarRenderer(this.locale);
|
|
454
544
|
|
|
455
545
|
this.options = {
|
|
456
|
-
container:
|
|
457
|
-
events:
|
|
458
|
-
view:
|
|
459
|
-
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:
|
|
462
|
-
onEventClick:
|
|
463
|
-
onChange:
|
|
464
|
-
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
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
639
|
+
const days = CalendarLogic.getWeekDays(this.currentDate, this.locale.firstDayOfWeek);
|
|
564
640
|
const first = days[0];
|
|
565
|
-
const last
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
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="
|
|
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="
|
|
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 ${
|
|
593
|
-
<button class="cal__btn ${
|
|
594
|
-
<button class="cal__btn ${
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
);
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
635
|
-
private readonly boundHandleKeydown = (e: KeyboardEvent)
|
|
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',
|
|
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
|
|
650
|
-
else if (action === 'next')
|
|
651
|
-
else if (action === 'today')
|
|
652
|
-
else if (action === 'view-month')
|
|
653
|
-
else if (action === 'view-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
|
|
662
|
-
const event = this.events.find(
|
|
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-
|
|
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
|
+
}
|