@dodlhuat/basix 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -1
- package/css/accordion.scss +86 -87
- package/css/alert.scss +137 -137
- package/css/button.scss +48 -0
- package/css/calendar.scss +957 -0
- package/css/card.scss +65 -65
- package/css/chart.scss +270 -157
- package/css/chat-bubbles.scss +134 -68
- package/css/chips.scss +109 -19
- package/css/colors.scss +32 -32
- package/css/datepicker.scss +336 -336
- package/css/defaults.scss +90 -90
- package/css/docs.scss +529 -0
- package/css/editor.scss +36 -0
- package/css/file-uploader.scss +1 -1
- package/css/flyout-menu.scss +361 -361
- package/css/form.scss +0 -15
- package/css/gallery.scss +65 -6
- package/css/grid.scss +41 -40
- package/css/group-picker.scss +345 -0
- package/css/guitar-chords.css +250 -250
- package/css/icons.scss +330 -330
- package/css/parameters.scss +3 -3
- package/css/placeholder.scss +33 -33
- package/css/popover.scss +206 -0
- package/css/progress.scss +76 -32
- package/css/properties.scss +51 -36
- package/css/push-menu.scss +302 -174
- package/css/reset.scss +39 -39
- package/css/scrollbar.scss +62 -5
- package/css/sidebar-nav.scss +92 -0
- package/css/spinner.scss +65 -65
- package/css/stepper.scss +48 -12
- package/css/style.css +3159 -254
- package/css/style.css.map +1 -1
- package/css/style.min.css +1 -1
- package/css/style.scss +51 -45
- package/css/table.scss +199 -199
- package/css/tabs.scss +154 -123
- package/css/timeline.scss +83 -38
- package/css/timepicker.scss +100 -5
- package/css/toast.scss +81 -81
- package/css/virtual-dropdown.scss +35 -29
- package/js/calendar.js +532 -0
- package/js/calendar.ts +706 -0
- package/js/chart.js +573 -257
- package/js/chart.ts +692 -0
- package/js/code-viewer.js +10 -10
- package/js/code-viewer.ts +188 -188
- package/js/datepicker.ts +627 -627
- package/js/docs-nav.js +204 -0
- package/js/dropdown.ts +179 -179
- package/js/editor.js +50 -6
- package/js/editor.ts +483 -444
- package/js/file-uploader.js +1 -0
- package/js/file-uploader.ts +1 -0
- package/js/flyout-menu.js +14 -14
- package/js/flyout-menu.ts +249 -249
- package/js/form-builder.js +106 -106
- package/js/gallery.js +14 -8
- package/js/gallery.ts +245 -236
- package/js/group-picker.js +342 -0
- package/js/group-picker.ts +447 -0
- package/js/guitar-chords.js +268 -268
- package/js/lazy-loader.js +121 -121
- package/js/modal.ts +166 -166
- package/js/popover.js +163 -0
- package/js/popover.ts +219 -0
- package/js/position.js +108 -0
- package/js/position.ts +111 -0
- package/js/push-menu.js +113 -0
- package/js/push-menu.ts +284 -145
- package/js/request.js +50 -50
- package/js/scroll.ts +47 -47
- package/js/scrollbar.js +13 -0
- package/js/scrollbar.ts +324 -307
- package/js/select.ts +216 -216
- package/js/sidebar-nav.js +41 -0
- package/js/sidebar-nav.ts +66 -0
- package/js/table.ts +452 -452
- package/js/tabs.ts +279 -279
- package/js/theme.js +17 -6
- package/js/theme.ts +234 -224
- package/js/toast.ts +137 -137
- package/js/tooltip.js +6 -60
- package/js/tooltip.ts +184 -251
- package/js/tsconfig.json +18 -18
- package/js/utils.ts +83 -83
- package/js/virtual-dropdown.js +25 -25
- package/js/virtual-dropdown.ts +365 -365
- package/package.json +39 -39
- package/js/index.js +0 -816
- package/js/index.ts +0 -987
package/js/calendar.ts
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// calendar.ts — Basix Calendar Component
|
|
3
|
+
// Integrates with @dodlhuat/basix design tokens & conventions
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
// -----------------------------------------------------------
|
|
7
|
+
// Types & Interfaces
|
|
8
|
+
// -----------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export interface CalendarEvent {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
start: Date;
|
|
14
|
+
end: Date;
|
|
15
|
+
allDay?: boolean;
|
|
16
|
+
/** Extra CSS class — use Basix badge/alert classes e.g. "badge-success" */
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CalendarView = 'month' | 'week' | 'agenda';
|
|
21
|
+
|
|
22
|
+
export interface CalendarLocale {
|
|
23
|
+
monthNames: string[];
|
|
24
|
+
dayNamesShort: string[];
|
|
25
|
+
dayNamesFull: string[];
|
|
26
|
+
/** 0 = Sunday, 1 = Monday */
|
|
27
|
+
firstDayOfWeek: number;
|
|
28
|
+
today: string;
|
|
29
|
+
month: string;
|
|
30
|
+
week: string;
|
|
31
|
+
agenda: string;
|
|
32
|
+
allDay: string;
|
|
33
|
+
noEvents: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CalendarOptions {
|
|
37
|
+
/** Target container element or CSS selector */
|
|
38
|
+
container: HTMLElement | string;
|
|
39
|
+
events?: CalendarEvent[];
|
|
40
|
+
view?: CalendarView;
|
|
41
|
+
locale?: Partial<CalendarLocale>;
|
|
42
|
+
/** Show days outside the current month in month view */
|
|
43
|
+
showOutsideDays?: boolean;
|
|
44
|
+
/** Callback when a day cell is clicked */
|
|
45
|
+
onDayClick?: (date: Date) => void;
|
|
46
|
+
/** Callback when an event is clicked */
|
|
47
|
+
onEventClick?: (event: CalendarEvent) => void;
|
|
48
|
+
/** Callback when view or date changes */
|
|
49
|
+
onChange?: (date: Date, view: CalendarView) => void;
|
|
50
|
+
/** Extra CSS class injected on the root .cal element */
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// -----------------------------------------------------------
|
|
55
|
+
// Date Logic (pure functions, no side effects)
|
|
56
|
+
// -----------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
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
|
+
getMonthGrid(year: number, month: number, firstDayOfWeek: number): Date[] {
|
|
64
|
+
const firstOfMonth = new Date(year, month, 1);
|
|
65
|
+
const lastOfMonth = new Date(year, month + 1, 0);
|
|
66
|
+
|
|
67
|
+
// Leading days from previous month
|
|
68
|
+
let startDow = firstOfMonth.getDay() - firstDayOfWeek;
|
|
69
|
+
if (startDow < 0) startDow += 7;
|
|
70
|
+
|
|
71
|
+
const days: Date[] = [];
|
|
72
|
+
|
|
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
|
+
const remaining = 7 - (days.length % 7);
|
|
82
|
+
if (remaining < 7) {
|
|
83
|
+
for (let i = 1; i <= remaining; i++) {
|
|
84
|
+
days.push(new Date(year, month + 1, i));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return days;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/** Returns the 7 dates of the week containing `date`. */
|
|
92
|
+
getWeekDays(date: Date, firstDayOfWeek: number): Date[] {
|
|
93
|
+
const d = new Date(date);
|
|
94
|
+
const dow = d.getDay();
|
|
95
|
+
let diff = dow - firstDayOfWeek;
|
|
96
|
+
if (diff < 0) diff += 7;
|
|
97
|
+
d.setDate(d.getDate() - diff);
|
|
98
|
+
|
|
99
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
100
|
+
const day = new Date(d);
|
|
101
|
+
day.setDate(d.getDate() + i);
|
|
102
|
+
return day;
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
isSameDay(a: Date, b: Date): boolean {
|
|
107
|
+
return (
|
|
108
|
+
a.getFullYear() === b.getFullYear() &&
|
|
109
|
+
a.getMonth() === b.getMonth() &&
|
|
110
|
+
a.getDate() === b.getDate()
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
isToday(date: Date): boolean {
|
|
115
|
+
return CalendarLogic.isSameDay(date, new Date());
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
isCurrentMonth(date: Date, year: number, month: number): boolean {
|
|
119
|
+
return date.getFullYear() === year && date.getMonth() === month;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** Returns all events that fall (fully or partially) on a given day. */
|
|
123
|
+
getEventsForDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
|
|
124
|
+
const dayStart = new Date(day);
|
|
125
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
126
|
+
const dayEnd = new Date(day);
|
|
127
|
+
dayEnd.setHours(23, 59, 59, 999);
|
|
128
|
+
|
|
129
|
+
return events.filter(
|
|
130
|
+
(e) => e.start <= dayEnd && e.end >= dayStart
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/** Returns only allDay events for a day. */
|
|
135
|
+
getAllDayEvents(events: CalendarEvent[], day: Date): CalendarEvent[] {
|
|
136
|
+
return CalendarLogic.getEventsForDay(events, day).filter((e) => e.allDay);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/** Returns only timed events for a day. */
|
|
140
|
+
getTimedEvents(events: CalendarEvent[], day: Date): CalendarEvent[] {
|
|
141
|
+
return CalendarLogic.getEventsForDay(events, day).filter((e) => !e.allDay);
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/** Returns top-offset % and height % for a timed event within a day column. */
|
|
145
|
+
getEventPosition(event: CalendarEvent, day: Date): { top: number; height: number } {
|
|
146
|
+
const dayStart = new Date(day);
|
|
147
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
148
|
+
const dayEnd = new Date(day);
|
|
149
|
+
dayEnd.setHours(24, 0, 0, 0);
|
|
150
|
+
|
|
151
|
+
const totalMs = 24 * 60 * 60 * 1000;
|
|
152
|
+
const startMs = Math.max(event.start.getTime(), dayStart.getTime()) - dayStart.getTime();
|
|
153
|
+
const endMs = Math.min(event.end.getTime(), dayEnd.getTime()) - dayStart.getTime();
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
top: (startMs / totalMs) * 100,
|
|
157
|
+
height: Math.max(((endMs - startMs) / totalMs) * 100, 2), // min 2%
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
formatTime(date: Date): string {
|
|
162
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// -----------------------------------------------------------
|
|
167
|
+
// Default Locale
|
|
168
|
+
// -----------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const DEFAULT_LOCALE: CalendarLocale = {
|
|
171
|
+
monthNames: [
|
|
172
|
+
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
|
173
|
+
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
|
174
|
+
],
|
|
175
|
+
dayNamesShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
|
|
176
|
+
dayNamesFull: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
|
|
177
|
+
firstDayOfWeek: 1,
|
|
178
|
+
today: 'Heute',
|
|
179
|
+
month: 'Monat',
|
|
180
|
+
week: 'Woche',
|
|
181
|
+
agenda: 'Agenda',
|
|
182
|
+
allDay: 'Ganztägig',
|
|
183
|
+
noEvents: 'Keine Termine',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// -----------------------------------------------------------
|
|
187
|
+
// Renderer — builds DOM from CalendarLogic output
|
|
188
|
+
// -----------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export class CalendarRenderer {
|
|
191
|
+
private locale: CalendarLocale;
|
|
192
|
+
|
|
193
|
+
constructor(locale: CalendarLocale) {
|
|
194
|
+
this.locale = locale;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Ordered day-name headers respecting firstDayOfWeek */
|
|
198
|
+
renderWeekdayHeaders(): string {
|
|
199
|
+
const { dayNamesShort, firstDayOfWeek } = this.locale;
|
|
200
|
+
const ordered = [
|
|
201
|
+
...dayNamesShort.slice(firstDayOfWeek),
|
|
202
|
+
...dayNamesShort.slice(0, firstDayOfWeek),
|
|
203
|
+
];
|
|
204
|
+
return ordered
|
|
205
|
+
.map((name) => `<div class="cal__weekday" aria-label="${name}">${name}</div>`)
|
|
206
|
+
.join('');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
renderEvent(event: CalendarEvent, compact = false): string {
|
|
210
|
+
const extraClass = event.className ?? '';
|
|
211
|
+
if (compact) {
|
|
212
|
+
return `<div class="cal__event-pill ${extraClass}"
|
|
213
|
+
data-event-id="${event.id}"
|
|
214
|
+
role="button"
|
|
215
|
+
tabindex="0"
|
|
216
|
+
aria-label="${event.title}"
|
|
217
|
+
title="${event.title}">${event.title}</div>`;
|
|
218
|
+
}
|
|
219
|
+
return `<div class="cal__event-pill ${extraClass}"
|
|
220
|
+
data-event-id="${event.id}"
|
|
221
|
+
role="button"
|
|
222
|
+
tabindex="0"
|
|
223
|
+
aria-label="${event.title}, ${CalendarLogic.formatTime(event.start)} – ${CalendarLogic.formatTime(event.end)}"
|
|
224
|
+
title="${event.title}">
|
|
225
|
+
<span class="cal__event-time">${CalendarLogic.formatTime(event.start)}</span>
|
|
226
|
+
${event.title}
|
|
227
|
+
</div>`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
renderMonthDay(
|
|
231
|
+
date: Date,
|
|
232
|
+
currentMonth: number,
|
|
233
|
+
currentYear: number,
|
|
234
|
+
events: CalendarEvent[],
|
|
235
|
+
selectedDate: Date | null,
|
|
236
|
+
showOutsideDays: boolean
|
|
237
|
+
): string {
|
|
238
|
+
const dayEvents = CalendarLogic.getEventsForDay(events, date);
|
|
239
|
+
const isToday = CalendarLogic.isToday(date);
|
|
240
|
+
const isSelected = selectedDate ? CalendarLogic.isSameDay(date, selectedDate) : false;
|
|
241
|
+
const isOutside = !CalendarLogic.isCurrentMonth(date, currentYear, currentMonth);
|
|
242
|
+
|
|
243
|
+
if (isOutside && !showOutsideDays) {
|
|
244
|
+
return `<div class="cal__day cal__day--empty" aria-hidden="true"></div>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const classes = [
|
|
248
|
+
'cal__day',
|
|
249
|
+
isToday ? 'is-today' : '',
|
|
250
|
+
isSelected ? 'is-selected' : '',
|
|
251
|
+
isOutside ? 'cal__day--outside' : '',
|
|
252
|
+
dayEvents.length > 0 ? 'has-events' : '',
|
|
253
|
+
]
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.join(' ');
|
|
256
|
+
|
|
257
|
+
const eventsHtml = dayEvents
|
|
258
|
+
.slice(0, 3)
|
|
259
|
+
.map((e) => this.renderEvent(e, true))
|
|
260
|
+
.join('');
|
|
261
|
+
|
|
262
|
+
const moreCount = dayEvents.length - 3;
|
|
263
|
+
const moreHtml =
|
|
264
|
+
moreCount > 0
|
|
265
|
+
? `<div class="cal__event-more">+${moreCount}</div>`
|
|
266
|
+
: '';
|
|
267
|
+
|
|
268
|
+
return `<div class="${classes}"
|
|
269
|
+
role="gridcell"
|
|
270
|
+
tabindex="0"
|
|
271
|
+
aria-label="${date.toLocaleDateString()}"
|
|
272
|
+
aria-selected="${isSelected}"
|
|
273
|
+
data-date="${date.toISOString()}">
|
|
274
|
+
<span class="cal__day-num">${date.getDate()}</span>
|
|
275
|
+
<div class="cal__day-events">${eventsHtml}${moreHtml}</div>
|
|
276
|
+
</div>`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
renderMonthView(
|
|
280
|
+
year: number,
|
|
281
|
+
month: number,
|
|
282
|
+
events: CalendarEvent[],
|
|
283
|
+
selectedDate: Date | null,
|
|
284
|
+
showOutsideDays: boolean,
|
|
285
|
+
firstDayOfWeek: number
|
|
286
|
+
): string {
|
|
287
|
+
const days = CalendarLogic.getMonthGrid(year, month, firstDayOfWeek);
|
|
288
|
+
const cells = days
|
|
289
|
+
.map((d) =>
|
|
290
|
+
this.renderMonthDay(d, month, year, events, selectedDate, showOutsideDays)
|
|
291
|
+
)
|
|
292
|
+
.join('');
|
|
293
|
+
|
|
294
|
+
return `<div class="cal__month-grid" role="grid" aria-label="${this.locale.monthNames[month]} ${year}">
|
|
295
|
+
${this.renderWeekdayHeaders()}
|
|
296
|
+
${cells}
|
|
297
|
+
</div>`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
renderWeekView(
|
|
301
|
+
date: Date,
|
|
302
|
+
events: CalendarEvent[],
|
|
303
|
+
selectedDate: Date | null,
|
|
304
|
+
firstDayOfWeek: number
|
|
305
|
+
): string {
|
|
306
|
+
const days = CalendarLogic.getWeekDays(date, firstDayOfWeek);
|
|
307
|
+
|
|
308
|
+
// All-day row
|
|
309
|
+
const allDayCols = days
|
|
310
|
+
.map((d) => {
|
|
311
|
+
const adEvents = CalendarLogic.getAllDayEvents(events, d);
|
|
312
|
+
const pills = adEvents.map((e) => this.renderEvent(e, true)).join('');
|
|
313
|
+
return `<div class="cal__allday-col">${pills}</div>`;
|
|
314
|
+
})
|
|
315
|
+
.join('');
|
|
316
|
+
|
|
317
|
+
// Day column headers
|
|
318
|
+
const headCols = days
|
|
319
|
+
.map((d) => {
|
|
320
|
+
const isToday = CalendarLogic.isToday(d);
|
|
321
|
+
const isSelected = selectedDate ? CalendarLogic.isSameDay(d, selectedDate) : false;
|
|
322
|
+
const classes = [
|
|
323
|
+
'cal__week-head-day',
|
|
324
|
+
isToday ? 'is-today' : '',
|
|
325
|
+
isSelected ? 'is-selected' : '',
|
|
326
|
+
]
|
|
327
|
+
.filter(Boolean)
|
|
328
|
+
.join(' ');
|
|
329
|
+
|
|
330
|
+
const dow = this.locale.dayNamesShort[(d.getDay() + 7) % 7];
|
|
331
|
+
return `<div class="${classes}" data-date="${d.toISOString()}">
|
|
332
|
+
${dow}<span>${d.getDate()}</span>
|
|
333
|
+
</div>`;
|
|
334
|
+
})
|
|
335
|
+
.join('');
|
|
336
|
+
|
|
337
|
+
// Hour slots + events
|
|
338
|
+
const hourLabels = Array.from({ length: 24 }, (_, h) => {
|
|
339
|
+
const label = h === 0 ? '' : `${String(h).padStart(2, '0')}:00`;
|
|
340
|
+
return `<div class="cal__time-slot">${label}</div>`;
|
|
341
|
+
}).join('');
|
|
342
|
+
|
|
343
|
+
const dayCols = days
|
|
344
|
+
.map((d) => {
|
|
345
|
+
const timedEvents = CalendarLogic.getTimedEvents(events, d);
|
|
346
|
+
const hourCells = Array.from({ length: 24 }, () => `<div class="cal__day-col-hour"></div>`).join('');
|
|
347
|
+
|
|
348
|
+
const eventOverlays = timedEvents
|
|
349
|
+
.map((e) => {
|
|
350
|
+
const { top, height } = CalendarLogic.getEventPosition(e, d);
|
|
351
|
+
const extraClass = e.className ?? '';
|
|
352
|
+
return `<div class="cal__week-event ${extraClass}"
|
|
353
|
+
style="top:${top.toFixed(2)}%;height:${height.toFixed(2)}%"
|
|
354
|
+
data-event-id="${e.id}"
|
|
355
|
+
role="button"
|
|
356
|
+
tabindex="0"
|
|
357
|
+
aria-label="${e.title}">
|
|
358
|
+
<span class="cal__event-time">${CalendarLogic.formatTime(e.start)}</span>
|
|
359
|
+
${e.title}
|
|
360
|
+
</div>`;
|
|
361
|
+
})
|
|
362
|
+
.join('');
|
|
363
|
+
|
|
364
|
+
return `<div class="cal__day-col" data-date="${d.toISOString()}">${hourCells}${eventOverlays}</div>`;
|
|
365
|
+
})
|
|
366
|
+
.join('');
|
|
367
|
+
|
|
368
|
+
return `<div class="cal__week" role="grid">
|
|
369
|
+
<div class="cal__week-head">
|
|
370
|
+
<div class="cal__week-head-time"></div>
|
|
371
|
+
${headCols}
|
|
372
|
+
</div>
|
|
373
|
+
<div class="cal__allday">
|
|
374
|
+
<div class="cal__allday-label">${this.locale.allDay}</div>
|
|
375
|
+
${allDayCols}
|
|
376
|
+
</div>
|
|
377
|
+
<div class="cal__week-body">
|
|
378
|
+
<div class="cal__week-grid">
|
|
379
|
+
<div class="cal__time-col">${hourLabels}</div>
|
|
380
|
+
${dayCols}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
</div>`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
renderAgendaView(year: number, month: number, events: CalendarEvent[]): string {
|
|
387
|
+
// Collect all days in this month that have events
|
|
388
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
389
|
+
let html = '';
|
|
390
|
+
|
|
391
|
+
for (let d = 1; d <= daysInMonth; d++) {
|
|
392
|
+
const day = new Date(year, month, d);
|
|
393
|
+
const dayEvents = CalendarLogic.getEventsForDay(events, day);
|
|
394
|
+
if (dayEvents.length === 0) continue;
|
|
395
|
+
|
|
396
|
+
const isToday = CalendarLogic.isToday(day);
|
|
397
|
+
const dow = this.locale.dayNamesFull[day.getDay()];
|
|
398
|
+
|
|
399
|
+
html += `<div class="cal__agenda-day ${isToday ? 'is-today' : ''}">
|
|
400
|
+
<div class="cal__agenda-date">
|
|
401
|
+
<span class="cal__agenda-dow">${dow}</span>
|
|
402
|
+
<span class="cal__agenda-num ${isToday ? 'is-today' : ''}">${d}</span>
|
|
403
|
+
</div>
|
|
404
|
+
<div class="cal__agenda-events">
|
|
405
|
+
${dayEvents.map((e) => `
|
|
406
|
+
<div class="cal__agenda-event ${e.className ?? ''}"
|
|
407
|
+
data-event-id="${e.id}"
|
|
408
|
+
role="button"
|
|
409
|
+
tabindex="0">
|
|
410
|
+
<span class="cal__agenda-event-time">
|
|
411
|
+
${e.allDay ? this.locale.allDay : CalendarLogic.formatTime(e.start) + ' – ' + CalendarLogic.formatTime(e.end)}
|
|
412
|
+
</span>
|
|
413
|
+
<span class="cal__agenda-event-title">${e.title}</span>
|
|
414
|
+
</div>`).join('')}
|
|
415
|
+
</div>
|
|
416
|
+
</div>`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!html) {
|
|
420
|
+
html = `<div class="cal__agenda-empty">${this.locale.noEvents}</div>`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return `<div class="cal__agenda">${html}</div>`;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// -----------------------------------------------------------
|
|
428
|
+
// Calendar — main controller class
|
|
429
|
+
// -----------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
export class Calendar {
|
|
432
|
+
private container: HTMLElement;
|
|
433
|
+
private options: Required<CalendarOptions>;
|
|
434
|
+
private locale: CalendarLocale;
|
|
435
|
+
private renderer: CalendarRenderer;
|
|
436
|
+
|
|
437
|
+
private currentDate: Date;
|
|
438
|
+
private currentView: CalendarView;
|
|
439
|
+
private selectedDate: Date | null = null;
|
|
440
|
+
private events: CalendarEvent[] = [];
|
|
441
|
+
|
|
442
|
+
constructor(options: CalendarOptions) {
|
|
443
|
+
// Resolve container
|
|
444
|
+
if (typeof options.container === 'string') {
|
|
445
|
+
const el = document.querySelector<HTMLElement>(options.container);
|
|
446
|
+
if (!el) throw new Error(`Calendar: container "${options.container}" not found.`);
|
|
447
|
+
this.container = el;
|
|
448
|
+
} else {
|
|
449
|
+
this.container = options.container;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this.locale = { ...DEFAULT_LOCALE, ...(options.locale ?? {}) };
|
|
453
|
+
this.renderer = new CalendarRenderer(this.locale);
|
|
454
|
+
|
|
455
|
+
this.options = {
|
|
456
|
+
container: this.container,
|
|
457
|
+
events: options.events ?? [],
|
|
458
|
+
view: options.view ?? 'month',
|
|
459
|
+
locale: options.locale ?? {},
|
|
460
|
+
showOutsideDays: options.showOutsideDays ?? true,
|
|
461
|
+
onDayClick: options.onDayClick ?? (() => {}),
|
|
462
|
+
onEventClick: options.onEventClick ?? (() => {}),
|
|
463
|
+
onChange: options.onChange ?? (() => {}),
|
|
464
|
+
className: options.className ?? '',
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
this.events = [...this.options.events];
|
|
468
|
+
this.currentView = this.options.view;
|
|
469
|
+
this.currentDate = new Date();
|
|
470
|
+
|
|
471
|
+
this.render();
|
|
472
|
+
this.attachEvents();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ----------------------------------------------------------
|
|
476
|
+
// Public API
|
|
477
|
+
// ----------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
setView(view: CalendarView): void {
|
|
480
|
+
this.currentView = view;
|
|
481
|
+
this.render();
|
|
482
|
+
this.options.onChange(this.currentDate, this.currentView);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
next(): void {
|
|
486
|
+
if (this.currentView === 'month' || this.currentView === 'agenda') {
|
|
487
|
+
this.currentDate = new Date(
|
|
488
|
+
this.currentDate.getFullYear(),
|
|
489
|
+
this.currentDate.getMonth() + 1,
|
|
490
|
+
1
|
|
491
|
+
);
|
|
492
|
+
} else {
|
|
493
|
+
this.currentDate = new Date(
|
|
494
|
+
this.currentDate.getFullYear(),
|
|
495
|
+
this.currentDate.getMonth(),
|
|
496
|
+
this.currentDate.getDate() + 7
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
this.render();
|
|
500
|
+
this.options.onChange(this.currentDate, this.currentView);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
prev(): void {
|
|
504
|
+
if (this.currentView === 'month' || this.currentView === 'agenda') {
|
|
505
|
+
this.currentDate = new Date(
|
|
506
|
+
this.currentDate.getFullYear(),
|
|
507
|
+
this.currentDate.getMonth() - 1,
|
|
508
|
+
1
|
|
509
|
+
);
|
|
510
|
+
} else {
|
|
511
|
+
this.currentDate = new Date(
|
|
512
|
+
this.currentDate.getFullYear(),
|
|
513
|
+
this.currentDate.getMonth(),
|
|
514
|
+
this.currentDate.getDate() - 7
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
this.render();
|
|
518
|
+
this.options.onChange(this.currentDate, this.currentView);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
today(): void {
|
|
522
|
+
this.currentDate = new Date();
|
|
523
|
+
this.render();
|
|
524
|
+
this.options.onChange(this.currentDate, this.currentView);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
addEvent(event: CalendarEvent): void {
|
|
528
|
+
this.events.push(event);
|
|
529
|
+
this.render();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
removeEvent(id: string): void {
|
|
533
|
+
this.events = this.events.filter((e) => e.id !== id);
|
|
534
|
+
this.render();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
setEvents(events: CalendarEvent[]): void {
|
|
538
|
+
this.events = [...events];
|
|
539
|
+
this.render();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
getEvents(): CalendarEvent[] {
|
|
543
|
+
return [...this.events];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
destroy(): void {
|
|
547
|
+
this.container.innerHTML = '';
|
|
548
|
+
this.container.removeAttribute('data-cal');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ----------------------------------------------------------
|
|
552
|
+
// Internal rendering
|
|
553
|
+
// ----------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
private getTitle(): string {
|
|
556
|
+
const { monthNames } = this.locale;
|
|
557
|
+
const y = this.currentDate.getFullYear();
|
|
558
|
+
const m = this.currentDate.getMonth();
|
|
559
|
+
|
|
560
|
+
if (this.currentView === 'week') {
|
|
561
|
+
const days = CalendarLogic.getWeekDays(this.currentDate, this.locale.firstDayOfWeek);
|
|
562
|
+
const first = days[0];
|
|
563
|
+
const last = days[6];
|
|
564
|
+
if (first.getMonth() === last.getMonth()) {
|
|
565
|
+
return `${monthNames[first.getMonth()]} ${y}`;
|
|
566
|
+
}
|
|
567
|
+
return `${monthNames[first.getMonth()]} – ${monthNames[last.getMonth()]} ${y}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return `${monthNames[m]} ${y}`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private buildHeader(): string {
|
|
574
|
+
const activeMonth = this.currentView === 'month' || this.currentView === 'agenda' ? 'cal__btn--active' : '';
|
|
575
|
+
const activeWeek = this.currentView === 'week' ? 'cal__btn--active' : '';
|
|
576
|
+
const activeAgenda = this.currentView === 'agenda' ? 'cal__btn--active' : '';
|
|
577
|
+
|
|
578
|
+
return `<div class="cal__header">
|
|
579
|
+
<div class="cal__nav">
|
|
580
|
+
<button class="cal__btn cal__btn--today" data-action="today" aria-label="${this.locale.today}">${this.locale.today}</button>
|
|
581
|
+
<button class="cal__btn" data-action="prev" aria-label="Zurück">
|
|
582
|
+
<svg class="icon-svg" aria-hidden="true"><use href="svg-icons/icons.svg#chevron_left"/></svg>
|
|
583
|
+
</button>
|
|
584
|
+
<button class="cal__btn" data-action="next" aria-label="Vor">
|
|
585
|
+
<svg class="icon-svg" aria-hidden="true"><use href="svg-icons/icons.svg#chevron_right"/></svg>
|
|
586
|
+
</button>
|
|
587
|
+
</div>
|
|
588
|
+
<h2 class="cal__title" aria-live="polite">${this.getTitle()}</h2>
|
|
589
|
+
<div class="cal__view-toggle" role="group" aria-label="Ansicht wählen">
|
|
590
|
+
<button class="cal__btn ${activeMonth}" data-action="view-month" aria-pressed="${this.currentView === 'month'}">${this.locale.month}</button>
|
|
591
|
+
<button class="cal__btn ${activeWeek}" data-action="view-week" aria-pressed="${this.currentView === 'week'}">${this.locale.week}</button>
|
|
592
|
+
<button class="cal__btn ${activeAgenda}" data-action="view-agenda" aria-pressed="${this.currentView === 'agenda'}">${this.locale.agenda}</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private buildBody(): string {
|
|
598
|
+
const { firstDayOfWeek } = this.locale;
|
|
599
|
+
const y = this.currentDate.getFullYear();
|
|
600
|
+
const m = this.currentDate.getMonth();
|
|
601
|
+
|
|
602
|
+
switch (this.currentView) {
|
|
603
|
+
case 'month':
|
|
604
|
+
return this.renderer.renderMonthView(
|
|
605
|
+
y, m, this.events, this.selectedDate,
|
|
606
|
+
this.options.showOutsideDays, firstDayOfWeek
|
|
607
|
+
);
|
|
608
|
+
case 'week':
|
|
609
|
+
return this.renderer.renderWeekView(
|
|
610
|
+
this.currentDate, this.events, this.selectedDate, firstDayOfWeek
|
|
611
|
+
);
|
|
612
|
+
case 'agenda':
|
|
613
|
+
return this.renderer.renderAgendaView(y, m, this.events);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private render(): void {
|
|
618
|
+
const rootClass = ['cal', this.options.className].filter(Boolean).join(' ');
|
|
619
|
+
this.container.setAttribute('data-cal', this.currentView);
|
|
620
|
+
this.container.innerHTML = `<div class="${rootClass}" role="application" aria-label="Kalender">
|
|
621
|
+
${this.buildHeader()}
|
|
622
|
+
<div class="cal__body">
|
|
623
|
+
${this.buildBody()}
|
|
624
|
+
</div>
|
|
625
|
+
</div>`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ----------------------------------------------------------
|
|
629
|
+
// Event delegation
|
|
630
|
+
// ----------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
private attachEvents(): void {
|
|
633
|
+
this.container.addEventListener('click', (e) => this.handleClick(e));
|
|
634
|
+
this.container.addEventListener('keydown', (e) => this.handleKeydown(e));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private handleClick(e: MouseEvent): void {
|
|
638
|
+
const target = e.target as HTMLElement;
|
|
639
|
+
|
|
640
|
+
// Nav / view buttons
|
|
641
|
+
const btn = target.closest<HTMLElement>('[data-action]');
|
|
642
|
+
if (btn) {
|
|
643
|
+
const action = btn.dataset.action!;
|
|
644
|
+
if (action === 'prev') this.prev();
|
|
645
|
+
else if (action === 'next') this.next();
|
|
646
|
+
else if (action === 'today') this.today();
|
|
647
|
+
else if (action === 'view-month') this.setView('month');
|
|
648
|
+
else if (action === 'view-week') this.setView('week');
|
|
649
|
+
else if (action === 'view-agenda') this.setView('agenda');
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Event click
|
|
654
|
+
const eventEl = target.closest<HTMLElement>('[data-event-id]');
|
|
655
|
+
if (eventEl) {
|
|
656
|
+
const id = eventEl.dataset.eventId!;
|
|
657
|
+
const event = this.events.find((ev) => ev.id === id);
|
|
658
|
+
if (event) {
|
|
659
|
+
e.stopPropagation();
|
|
660
|
+
this.options.onEventClick(event);
|
|
661
|
+
}
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Day click
|
|
666
|
+
const dayEl = target.closest<HTMLElement>('[data-date]');
|
|
667
|
+
if (dayEl && dayEl.dataset.date) {
|
|
668
|
+
const date = new Date(dayEl.dataset.date);
|
|
669
|
+
this.selectedDate = date;
|
|
670
|
+
this.options.onDayClick(date);
|
|
671
|
+
// Re-render to update selection state
|
|
672
|
+
this.render();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private handleKeydown(e: KeyboardEvent): void {
|
|
677
|
+
const target = e.target as HTMLElement;
|
|
678
|
+
|
|
679
|
+
// Allow Enter/Space to trigger click on focused interactive elements
|
|
680
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
681
|
+
if (target.closest('[data-date], [data-event-id], [data-action]')) {
|
|
682
|
+
e.preventDefault();
|
|
683
|
+
target.click();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Arrow key navigation within month grid
|
|
688
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
|
|
689
|
+
const dayEl = target.closest<HTMLElement>('.cal__day[data-date]');
|
|
690
|
+
if (!dayEl) return;
|
|
691
|
+
|
|
692
|
+
e.preventDefault();
|
|
693
|
+
const all = Array.from(
|
|
694
|
+
this.container.querySelectorAll<HTMLElement>('.cal__day[data-date]:not(.cal__day--empty)')
|
|
695
|
+
);
|
|
696
|
+
const idx = all.indexOf(dayEl);
|
|
697
|
+
let next = idx;
|
|
698
|
+
|
|
699
|
+
if (e.key === 'ArrowRight') next = idx + 1;
|
|
700
|
+
else if (e.key === 'ArrowLeft') next = idx - 1;
|
|
701
|
+
else if (e.key === 'ArrowDown') next = idx + 7;
|
|
702
|
+
else if (e.key === 'ArrowUp') next = idx - 7;
|
|
703
|
+
|
|
704
|
+
all[Math.max(0, Math.min(next, all.length - 1))]?.focus();
|
|
705
|
+
}
|
|
706
|
+
}
|