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