@aiaiai-pt/design-system 0.4.3 → 0.5.0

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.
@@ -0,0 +1,971 @@
1
+ <!--
2
+ @component Calendar
3
+
4
+ Full-page scheduling calendar with month, week, and day views.
5
+ Events rendered as colored blocks with title, time, and optional status.
6
+ Navigation with prev/next, today button, and view switcher.
7
+ Consumes --calendar-* tokens from components.css.
8
+
9
+ @example Basic month view
10
+ <Calendar events={events} oneventclick={(ev) => console.log(ev)} />
11
+
12
+ @example Week view with locale
13
+ <Calendar view="week" events={events} locale={pt} />
14
+
15
+ @example Custom event rendering
16
+ <Calendar events={events}>
17
+ {#snippet event(ev)}
18
+ <span>{ev.title}</span>
19
+ <Badge>{ev.status}</Badge>
20
+ {/snippet}
21
+ </Calendar>
22
+ -->
23
+ <script>
24
+ import {
25
+ format,
26
+ startOfMonth, endOfMonth,
27
+ startOfWeek, endOfWeek,
28
+ startOfDay, endOfDay,
29
+ addDays, addWeeks, subWeeks, addMonths, subMonths,
30
+ isSameDay, isSameMonth, isBefore
31
+ } from 'date-fns';
32
+ import { enUS } from 'date-fns/locale';
33
+
34
+ /**
35
+ * @typedef {'month' | 'week' | 'day'} View
36
+ * @typedef {Object} CalendarEvent
37
+ * @property {string | number} id
38
+ * @property {string} title
39
+ * @property {Date} start
40
+ * @property {Date} [end]
41
+ * @property {string} [color] - CSS color or custom property
42
+ * @property {string} [status]
43
+ * @property {boolean} [allDay]
44
+ */
45
+
46
+ let {
47
+ /** @type {View} */
48
+ view = $bindable('month'),
49
+ /** @type {Date} */
50
+ date = $bindable(new Date()),
51
+ /** @type {CalendarEvent[]} */
52
+ events = [],
53
+ /** @type {number} — max visible events per month cell */
54
+ maxVisible = 3,
55
+ /** @type {import('date-fns').Locale} */
56
+ locale = enUS,
57
+ /** @type {((event: CalendarEvent) => void) | undefined} */
58
+ oneventclick = undefined,
59
+ /** @type {((date: Date) => void) | undefined} */
60
+ ondateclick = undefined,
61
+ /** @type {import('svelte').Snippet<[CalendarEvent]> | undefined} */
62
+ event: eventSnippet = undefined,
63
+ /** @type {string} */
64
+ class: className = '',
65
+ ...rest
66
+ } = $props();
67
+
68
+ // ─── Shared derived ───
69
+
70
+ const weekdays = $derived(getWeekdays(locale));
71
+ const hours = Array.from({ length: 24 }, (_, i) => i);
72
+
73
+ // ─── Month derived ───
74
+
75
+ const calendarDays = $derived(getCalendarDays(date, locale));
76
+ const weekCount = $derived(Math.ceil(calendarDays.length / 7));
77
+
78
+ // ─── Week derived ───
79
+
80
+ const weekDays = $derived(getWeekDayDates(date, locale));
81
+ const weekTitle = $derived(formatWeekTitle(date, locale));
82
+
83
+ // ─── Day derived ───
84
+
85
+ const dayTitle = $derived(format(date, 'EEEE, MMMM d', { locale }));
86
+ const dayIsToday = $derived(isSameDay(date, new Date()));
87
+ const dayViewEvents = $derived(getTimedEventsForDate(date));
88
+ const dayViewLayout = $derived(computeEventLayout(dayViewEvents));
89
+
90
+ // ─── Helpers: calendar grid generation ───
91
+
92
+ /** @param {import('date-fns').Locale} loc */
93
+ function getWeekdays(loc) {
94
+ const s = startOfWeek(new Date(), { locale: loc });
95
+ return Array.from({ length: 7 }, (_, i) =>
96
+ format(addDays(s, i), 'EEEEEE', { locale: loc })
97
+ );
98
+ }
99
+
100
+ /** @param {Date} d @param {import('date-fns').Locale} loc */
101
+ function getCalendarDays(d, loc) {
102
+ const monthStart = startOfMonth(d);
103
+ const monthEnd = endOfMonth(d);
104
+ const calStart = startOfWeek(monthStart, { locale: loc });
105
+ const calEnd = endOfWeek(monthEnd, { locale: loc });
106
+
107
+ /** @type {Date[]} */
108
+ const days = [];
109
+ let cursor = calStart;
110
+ while (isBefore(cursor, calEnd) || isSameDay(cursor, calEnd)) {
111
+ days.push(cursor);
112
+ cursor = addDays(cursor, 1);
113
+ }
114
+ return days;
115
+ }
116
+
117
+ /** @param {Date} d @param {import('date-fns').Locale} loc */
118
+ function getWeekDayDates(d, loc) {
119
+ const ws = startOfWeek(d, { locale: loc });
120
+ return Array.from({ length: 7 }, (_, i) => addDays(ws, i));
121
+ }
122
+
123
+ /** @param {Date} d @param {import('date-fns').Locale} loc */
124
+ function formatWeekTitle(d, loc) {
125
+ const ws = startOfWeek(d, { locale: loc });
126
+ const we = endOfWeek(d, { locale: loc });
127
+ if (ws.getMonth() === we.getMonth()) {
128
+ return `${format(ws, 'MMM d', { locale: loc })} – ${format(we, 'd, yyyy', { locale: loc })}`;
129
+ }
130
+ return `${format(ws, 'MMM d', { locale: loc })} – ${format(we, 'MMM d, yyyy', { locale: loc })}`;
131
+ }
132
+
133
+ // ─── Helpers: event filtering ───
134
+
135
+ /** @param {CalendarEvent} ev @param {Date} day */
136
+ function isEventOnDate(ev, day) {
137
+ if (!ev.end) return isSameDay(ev.start, day);
138
+ const dayStart = startOfDay(day);
139
+ const dayEnd = endOfDay(day);
140
+ return ev.start <= dayEnd && ev.end >= dayStart;
141
+ }
142
+
143
+ /** @param {Date} day */
144
+ function getEventsForDate(day) {
145
+ return events.filter(ev => isEventOnDate(ev, day));
146
+ }
147
+
148
+ /** @param {Date} day — timed (non-allDay) events starting on this day */
149
+ function getTimedEventsForDate(day) {
150
+ return events.filter(ev =>
151
+ !ev.allDay && isSameDay(ev.start, day)
152
+ );
153
+ }
154
+
155
+ // ─── Helpers: week/day event positioning ───
156
+
157
+ /** @param {CalendarEvent} ev */
158
+ function getEventTopHours(ev) {
159
+ return ev.start.getHours() + ev.start.getMinutes() / 60;
160
+ }
161
+
162
+ /** @param {CalendarEvent} ev */
163
+ function getEventDurationHours(ev) {
164
+ if (!ev.end) return 1;
165
+ const mins = (ev.end.getTime() - ev.start.getTime()) / 60000;
166
+ return Math.max(mins / 60, 0.5); // min half-hour height
167
+ }
168
+
169
+ /**
170
+ * Compute column layout for overlapping events within a single day.
171
+ * Returns Map<id, { column: number, totalColumns: number }>
172
+ * @param {CalendarEvent[]} dayEvents
173
+ */
174
+ function computeEventLayout(dayEvents) {
175
+ /** @type {Map<string|number, { column: number, totalColumns: number }>} */
176
+ const result = new Map();
177
+ if (dayEvents.length === 0) return result;
178
+
179
+ const sorted = [...dayEvents].sort((a, b) => a.start.getTime() - b.start.getTime());
180
+
181
+ // Split into non-overlapping clusters
182
+ /** @type {CalendarEvent[][]} */
183
+ const clusters = [];
184
+ let cluster = [sorted[0]];
185
+ let clusterEnd = sorted[0].end || new Date(sorted[0].start.getTime() + 3600000);
186
+
187
+ for (let i = 1; i < sorted.length; i++) {
188
+ const ev = sorted[i];
189
+ if (ev.start < clusterEnd) {
190
+ cluster.push(ev);
191
+ const evEnd = ev.end || new Date(ev.start.getTime() + 3600000);
192
+ if (evEnd > clusterEnd) clusterEnd = evEnd;
193
+ } else {
194
+ clusters.push(cluster);
195
+ cluster = [ev];
196
+ clusterEnd = ev.end || new Date(ev.start.getTime() + 3600000);
197
+ }
198
+ }
199
+ clusters.push(cluster);
200
+
201
+ for (const group of clusters) {
202
+ /** @type {Date[]} */
203
+ const columns = [];
204
+ for (const ev of group) {
205
+ const evEnd = ev.end || new Date(ev.start.getTime() + 3600000);
206
+ let col = columns.findIndex(colEnd => colEnd <= ev.start);
207
+ if (col === -1) {
208
+ col = columns.length;
209
+ columns.push(evEnd);
210
+ } else {
211
+ columns[col] = evEnd;
212
+ }
213
+ result.set(ev.id, { column: col, totalColumns: 0 });
214
+ }
215
+ for (const ev of group) {
216
+ const layout = result.get(ev.id);
217
+ if (layout) layout.totalColumns = columns.length;
218
+ }
219
+ }
220
+
221
+ return result;
222
+ }
223
+
224
+ // ─── Now indicator ───
225
+
226
+ /** @returns {{ hours: number, isToday: boolean }} */
227
+ function getNowPosition() {
228
+ const now = new Date();
229
+ return {
230
+ hours: now.getHours() + now.getMinutes() / 60,
231
+ isToday: true
232
+ };
233
+ }
234
+
235
+ const nowPos = $derived(getNowPosition());
236
+
237
+ // ─── Navigation ───
238
+
239
+ function prev() {
240
+ if (view === 'month') date = subMonths(date, 1);
241
+ else if (view === 'week') date = subWeeks(date, 1);
242
+ else date = addDays(date, -1);
243
+ }
244
+
245
+ function next() {
246
+ if (view === 'month') date = addMonths(date, 1);
247
+ else if (view === 'week') date = addWeeks(date, 1);
248
+ else date = addDays(date, 1);
249
+ }
250
+
251
+ function goToday() {
252
+ date = new Date();
253
+ }
254
+
255
+ /** @param {CalendarEvent} ev */
256
+ function handleEventClick(ev) {
257
+ oneventclick?.(ev);
258
+ }
259
+
260
+ /** @param {Date} d */
261
+ function handleDateClick(d) {
262
+ ondateclick?.(d);
263
+ }
264
+ </script>
265
+
266
+ <div class="calendar {className}" {...rest}>
267
+ <!-- ═══ Toolbar ═══ -->
268
+ <!-- Toolbar buttons blur() on click to prevent stale focus rings when user presses keys afterward -->
269
+ <div class="calendar-toolbar">
270
+ <div class="calendar-toolbar-left">
271
+ <button type="button" class="calendar-nav-btn" onclick={(e) => { prev(); /** @type {HTMLElement} */ (e.currentTarget).blur(); }} aria-label="Previous">
272
+ <svg viewBox="0 0 256 256" aria-hidden="true">
273
+ <polyline points="160,208 80,128 160,48" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" />
274
+ </svg>
275
+ </button>
276
+ <button type="button" class="calendar-nav-btn" onclick={(e) => { next(); /** @type {HTMLElement} */ (e.currentTarget).blur(); }} aria-label="Next">
277
+ <svg viewBox="0 0 256 256" aria-hidden="true">
278
+ <polyline points="96,48 176,128 96,208" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round" />
279
+ </svg>
280
+ </button>
281
+ <button type="button" class="calendar-today-btn" onclick={(e) => { goToday(); /** @type {HTMLElement} */ (e.currentTarget).blur(); }}>Today</button>
282
+ <h2 class="calendar-title">
283
+ {#if view === 'month'}
284
+ {format(date, 'LLLL yyyy', { locale })}
285
+ {:else if view === 'week'}
286
+ {weekTitle}
287
+ {:else}
288
+ {dayTitle}
289
+ {/if}
290
+ </h2>
291
+ </div>
292
+ <div class="calendar-toolbar-right">
293
+ <div class="calendar-view-toggle" role="group" aria-label="Calendar view">
294
+ <button
295
+ type="button"
296
+ class="calendar-toggle-btn"
297
+ class:calendar-toggle-active={view === 'month'}
298
+ onclick={(e) => { view = 'month'; /** @type {HTMLElement} */ (e.currentTarget).blur(); }}
299
+ >Month</button>
300
+ <button
301
+ type="button"
302
+ class="calendar-toggle-btn"
303
+ class:calendar-toggle-active={view === 'week'}
304
+ onclick={(e) => { view = 'week'; /** @type {HTMLElement} */ (e.currentTarget).blur(); }}
305
+ >Week</button>
306
+ <button
307
+ type="button"
308
+ class="calendar-toggle-btn"
309
+ class:calendar-toggle-active={view === 'day'}
310
+ onclick={(e) => { view = 'day'; /** @type {HTMLElement} */ (e.currentTarget).blur(); }}
311
+ >Day</button>
312
+ </div>
313
+ </div>
314
+ </div>
315
+
316
+ <!-- ═══ Month View ═══ -->
317
+ {#if view === 'month'}
318
+ <div class="calendar-month" role="grid" aria-label="Calendar month view">
319
+ <div class="calendar-weekday-row" role="row">
320
+ {#each weekdays as day}
321
+ <span class="calendar-weekday" role="columnheader">{day}</span>
322
+ {/each}
323
+ </div>
324
+
325
+ <div
326
+ class="calendar-month-grid"
327
+ role="rowgroup"
328
+ style="grid-template-rows: repeat({weekCount}, minmax(var(--calendar-cell-min-height), 1fr));"
329
+ >
330
+ {#each calendarDays as day}
331
+ {@const isToday = isSameDay(day, new Date())}
332
+ {@const isOutside = !isSameMonth(day, date)}
333
+ {@const dayEvents = getEventsForDate(day)}
334
+ {@const visibleEvents = dayEvents.slice(0, maxVisible)}
335
+ {@const overflow = dayEvents.length - maxVisible}
336
+ <div
337
+ class="calendar-cell"
338
+ class:calendar-cell-today={isToday}
339
+ class:calendar-cell-outside={isOutside}
340
+ role="gridcell"
341
+ aria-label={format(day, 'EEEE, MMMM d', { locale })}
342
+ onclick={() => handleDateClick(day)}
343
+ >
344
+ <span class="calendar-day-number" class:calendar-day-today={isToday}>
345
+ {day.getDate()}
346
+ </span>
347
+ <div class="calendar-cell-events">
348
+ {#each visibleEvents as ev (ev.id)}
349
+ <button
350
+ type="button"
351
+ class="calendar-event-pill"
352
+ style="background: {ev.color || 'var(--calendar-event-default-bg)'}; color: {ev.color ? 'var(--color-text-on-accent)' : 'var(--calendar-event-default-text)'};"
353
+ onclick={(e) => { e.stopPropagation(); handleEventClick(ev); }}
354
+ title={ev.title}
355
+ >
356
+ {#if eventSnippet}
357
+ {@render eventSnippet(ev)}
358
+ {:else}
359
+ <span class="calendar-event-pill-title">{ev.title}</span>
360
+ {/if}
361
+ </button>
362
+ {/each}
363
+ {#if overflow > 0}
364
+ <span class="calendar-overflow">+{overflow} more</span>
365
+ {/if}
366
+ </div>
367
+ </div>
368
+ {/each}
369
+ </div>
370
+ </div>
371
+
372
+ <!-- ═══ Week View ═══ -->
373
+ {:else if view === 'week'}
374
+ <div class="calendar-week" role="grid" aria-label="Calendar week view">
375
+ <!-- Weekday header row -->
376
+ <div class="calendar-week-header">
377
+ <div class="calendar-time-spacer"></div>
378
+ {#each weekDays as day}
379
+ {@const isToday = isSameDay(day, new Date())}
380
+ <div class="calendar-week-day-header" class:calendar-week-day-today={isToday}>
381
+ <span class="calendar-week-day-name">{format(day, 'EEE', { locale })}</span>
382
+ <span class="calendar-week-day-num" class:calendar-week-day-num-today={isToday}>{day.getDate()}</span>
383
+ </div>
384
+ {/each}
385
+ </div>
386
+
387
+ <!-- Scrollable time grid -->
388
+ <div class="calendar-week-body">
389
+ <!-- Time gutter -->
390
+ <div class="calendar-time-gutter">
391
+ {#each hours as hour}
392
+ <div class="calendar-time-label">
393
+ {String(hour).padStart(2, '0')}:00
394
+ </div>
395
+ {/each}
396
+ </div>
397
+
398
+ <!-- Day columns -->
399
+ {#each weekDays as day}
400
+ {@const isToday = isSameDay(day, new Date())}
401
+ {@const dayEvents = getTimedEventsForDate(day)}
402
+ {@const layout = computeEventLayout(dayEvents)}
403
+ <div class="calendar-day-column" onclick={() => handleDateClick(day)}>
404
+ {#each hours as hour}
405
+ <div class="calendar-hour-slot"></div>
406
+ {/each}
407
+
408
+ <!-- Now indicator -->
409
+ {#if isToday}
410
+ <div
411
+ class="calendar-now-line"
412
+ style="top: calc({nowPos.hours} * var(--calendar-slot-height));"
413
+ >
414
+ <div class="calendar-now-dot"></div>
415
+ </div>
416
+ {/if}
417
+
418
+ <!-- Events -->
419
+ {#each dayEvents as ev (ev.id)}
420
+ {@const pos = layout.get(ev.id)}
421
+ {@const top = getEventTopHours(ev)}
422
+ {@const height = getEventDurationHours(ev)}
423
+ <button
424
+ type="button"
425
+ class="calendar-event-block"
426
+ style="
427
+ top: calc({top} * var(--calendar-slot-height));
428
+ height: calc({height} * var(--calendar-slot-height));
429
+ left: calc({pos ? pos.column / pos.totalColumns * 100 : 0}%);
430
+ width: calc({pos ? 100 / pos.totalColumns : 100}%);
431
+ background: {ev.color || 'var(--calendar-event-default-bg)'};
432
+ color: {ev.color ? 'var(--color-text-on-accent)' : 'var(--calendar-event-default-text)'};
433
+ "
434
+ onclick={(e) => { e.stopPropagation(); handleEventClick(ev); }}
435
+ title="{ev.title} ({format(ev.start, 'HH:mm', { locale })}{ev.end ? ' – ' + format(ev.end, 'HH:mm', { locale }) : ''})"
436
+ >
437
+ {#if eventSnippet}
438
+ {@render eventSnippet(ev)}
439
+ {:else}
440
+ <span class="calendar-event-time">{format(ev.start, 'HH:mm', { locale })}</span>
441
+ <span class="calendar-event-title">{ev.title}</span>
442
+ {/if}
443
+ </button>
444
+ {/each}
445
+ </div>
446
+ {/each}
447
+ </div>
448
+ </div>
449
+
450
+ <!-- ═══ Day View ═══ -->
451
+ {:else}
452
+ <div class="calendar-day-view" role="grid" aria-label="Calendar day view">
453
+ <!-- Day header -->
454
+ <div class="calendar-day-header">
455
+ <div class="calendar-time-spacer"></div>
456
+ <div class="calendar-day-header-cell" class:calendar-week-day-today={dayIsToday}>
457
+ <span class="calendar-week-day-name">{format(date, 'EEEE', { locale })}</span>
458
+ <span class="calendar-week-day-num" class:calendar-week-day-num-today={dayIsToday}>{date.getDate()}</span>
459
+ </div>
460
+ </div>
461
+
462
+ <!-- Scrollable time grid -->
463
+ <div class="calendar-day-body">
464
+ <div class="calendar-time-gutter">
465
+ {#each hours as hour}
466
+ <div class="calendar-time-label">
467
+ {String(hour).padStart(2, '0')}:00
468
+ </div>
469
+ {/each}
470
+ </div>
471
+
472
+ <div class="calendar-day-column" onclick={() => handleDateClick(date)}>
473
+ {#each hours as hour}
474
+ <div class="calendar-hour-slot"></div>
475
+ {/each}
476
+
477
+ {#if dayIsToday}
478
+ <div
479
+ class="calendar-now-line"
480
+ style="top: calc({nowPos.hours} * var(--calendar-slot-height));"
481
+ >
482
+ <div class="calendar-now-dot"></div>
483
+ </div>
484
+ {/if}
485
+
486
+ {#each dayViewEvents as ev (ev.id)}
487
+ {@const pos = dayViewLayout.get(ev.id)}
488
+ {@const top = getEventTopHours(ev)}
489
+ {@const height = getEventDurationHours(ev)}
490
+ <button
491
+ type="button"
492
+ class="calendar-event-block"
493
+ style="
494
+ top: calc({top} * var(--calendar-slot-height));
495
+ height: calc({height} * var(--calendar-slot-height));
496
+ left: calc({pos ? pos.column / pos.totalColumns * 100 : 0}%);
497
+ width: calc({pos ? 100 / pos.totalColumns : 100}%);
498
+ background: {ev.color || 'var(--calendar-event-default-bg)'};
499
+ color: {ev.color ? 'var(--color-text-on-accent)' : 'var(--calendar-event-default-text)'};
500
+ "
501
+ onclick={(e) => { e.stopPropagation(); handleEventClick(ev); }}
502
+ title="{ev.title} ({format(ev.start, 'HH:mm', { locale })}{ev.end ? ' – ' + format(ev.end, 'HH:mm', { locale }) : ''})"
503
+ >
504
+ {#if eventSnippet}
505
+ {@render eventSnippet(ev)}
506
+ {:else}
507
+ <span class="calendar-event-time">{format(ev.start, 'HH:mm', { locale })}</span>
508
+ <span class="calendar-event-title">{ev.title}</span>
509
+ {/if}
510
+ </button>
511
+ {/each}
512
+ </div>
513
+ </div>
514
+ </div>
515
+ {/if}
516
+ </div>
517
+
518
+ <style>
519
+ /* ═══ Container ═══ */
520
+ .calendar {
521
+ display: flex;
522
+ flex-direction: column;
523
+ background: var(--calendar-bg);
524
+ border: var(--calendar-border);
525
+ border-radius: var(--calendar-radius);
526
+ padding: var(--calendar-padding);
527
+ min-height: 0;
528
+ }
529
+
530
+ /* ═══ Toolbar ═══ */
531
+ .calendar-toolbar {
532
+ display: flex;
533
+ align-items: center;
534
+ justify-content: space-between;
535
+ gap: var(--calendar-toolbar-gap);
536
+ margin-bottom: var(--calendar-padding);
537
+ flex-wrap: wrap;
538
+ }
539
+
540
+ .calendar-toolbar-left {
541
+ display: flex;
542
+ align-items: center;
543
+ gap: var(--calendar-toolbar-gap);
544
+ }
545
+
546
+ .calendar-toolbar-right {
547
+ display: flex;
548
+ align-items: center;
549
+ }
550
+
551
+ .calendar-title {
552
+ font-family: var(--calendar-title-font);
553
+ font-size: var(--calendar-title-size);
554
+ font-weight: var(--calendar-title-weight);
555
+ letter-spacing: var(--calendar-title-tracking);
556
+ color: var(--calendar-title-color);
557
+ margin: 0;
558
+ }
559
+
560
+ /* ─── Nav buttons ─── */
561
+ .calendar-nav-btn {
562
+ display: flex;
563
+ align-items: center;
564
+ justify-content: center;
565
+ width: var(--calendar-nav-btn-size);
566
+ height: var(--calendar-nav-btn-size);
567
+ border: none;
568
+ border-radius: var(--calendar-nav-btn-radius);
569
+ background: transparent;
570
+ color: var(--color-text-secondary);
571
+ cursor: pointer;
572
+ transition: background var(--duration-instant) var(--easing-default);
573
+ }
574
+
575
+ .calendar-nav-btn:hover {
576
+ background: var(--calendar-nav-btn-hover-bg);
577
+ }
578
+
579
+ .calendar-nav-btn svg {
580
+ width: var(--icon-size-xs);
581
+ height: var(--icon-size-xs);
582
+ }
583
+
584
+ /* ─── Today button ─── */
585
+ .calendar-today-btn {
586
+ font-family: var(--calendar-toggle-font);
587
+ font-size: var(--calendar-toggle-size);
588
+ letter-spacing: var(--calendar-toggle-tracking);
589
+ padding: var(--calendar-toggle-padding);
590
+ border: var(--calendar-cell-border);
591
+ border-radius: var(--calendar-toggle-radius);
592
+ background: transparent;
593
+ color: var(--calendar-toggle-color);
594
+ cursor: pointer;
595
+ transition: background var(--duration-instant) var(--easing-default);
596
+ }
597
+
598
+ .calendar-today-btn:hover {
599
+ background: var(--calendar-toggle-hover-bg);
600
+ }
601
+
602
+ /* ─── View toggle ─── */
603
+ .calendar-view-toggle {
604
+ display: flex;
605
+ border: var(--calendar-cell-border);
606
+ border-radius: var(--calendar-toggle-radius);
607
+ overflow: hidden;
608
+ }
609
+
610
+ .calendar-toggle-btn {
611
+ font-family: var(--calendar-toggle-font);
612
+ font-size: var(--calendar-toggle-size);
613
+ letter-spacing: var(--calendar-toggle-tracking);
614
+ padding: var(--calendar-toggle-padding);
615
+ border: none;
616
+ background: transparent;
617
+ color: var(--calendar-toggle-color);
618
+ cursor: pointer;
619
+ transition: background var(--duration-instant) var(--easing-default),
620
+ color var(--duration-instant) var(--easing-default);
621
+ }
622
+
623
+ .calendar-toggle-btn:hover:not(.calendar-toggle-active) {
624
+ background: var(--calendar-toggle-hover-bg);
625
+ }
626
+
627
+ .calendar-toggle-active {
628
+ background: var(--calendar-toggle-active-bg);
629
+ color: var(--calendar-toggle-active-text);
630
+ }
631
+
632
+ /* ═══ Month View ═══ */
633
+ .calendar-month {
634
+ display: flex;
635
+ flex-direction: column;
636
+ flex: 1;
637
+ min-height: 0;
638
+ }
639
+
640
+ .calendar-weekday-row {
641
+ display: grid;
642
+ grid-template-columns: repeat(7, 1fr);
643
+ }
644
+
645
+ .calendar-weekday {
646
+ display: flex;
647
+ align-items: center;
648
+ justify-content: center;
649
+ height: var(--calendar-weekday-height);
650
+ font-family: var(--calendar-weekday-font);
651
+ font-size: var(--calendar-weekday-size);
652
+ letter-spacing: var(--calendar-weekday-tracking);
653
+ color: var(--calendar-weekday-color);
654
+ text-transform: uppercase;
655
+ }
656
+
657
+ .calendar-month-grid {
658
+ display: grid;
659
+ grid-template-columns: repeat(7, 1fr);
660
+ flex: 1;
661
+ min-height: 0;
662
+ }
663
+
664
+ .calendar-cell {
665
+ display: flex;
666
+ flex-direction: column;
667
+ gap: var(--space-2xs);
668
+ padding: var(--calendar-cell-padding);
669
+ border-right: var(--calendar-cell-border);
670
+ border-bottom: var(--calendar-cell-border);
671
+ cursor: pointer;
672
+ transition: background var(--duration-instant) var(--easing-default);
673
+ overflow: hidden;
674
+ min-height: 0;
675
+ }
676
+
677
+ .calendar-cell:hover {
678
+ background: var(--calendar-cell-hover-bg);
679
+ }
680
+
681
+ .calendar-cell:nth-child(7n + 1) {
682
+ border-left: var(--calendar-cell-border);
683
+ }
684
+
685
+ .calendar-cell:nth-child(-n + 7) {
686
+ border-top: var(--calendar-cell-border);
687
+ }
688
+
689
+ .calendar-cell-today {
690
+ background: var(--calendar-cell-today-bg);
691
+ }
692
+
693
+ .calendar-cell-outside {
694
+ opacity: var(--calendar-day-outside-opacity);
695
+ }
696
+
697
+ /* ─── Day number ─── */
698
+ .calendar-day-number {
699
+ font-family: var(--calendar-day-font);
700
+ font-size: var(--calendar-day-size);
701
+ color: var(--calendar-day-color);
702
+ line-height: 1;
703
+ align-self: flex-end;
704
+ padding: var(--space-2xs);
705
+ }
706
+
707
+ .calendar-day-today {
708
+ display: inline-flex;
709
+ align-items: center;
710
+ justify-content: center;
711
+ width: var(--calendar-day-today-size);
712
+ height: var(--calendar-day-today-size);
713
+ background: var(--calendar-day-today-bg);
714
+ color: var(--calendar-day-today-text);
715
+ border-radius: var(--radius-circle);
716
+ font-weight: 600;
717
+ }
718
+
719
+ /* ─── Month event pills ─── */
720
+ .calendar-cell-events {
721
+ display: flex;
722
+ flex-direction: column;
723
+ gap: var(--space-2xs);
724
+ min-height: 0;
725
+ overflow: hidden;
726
+ }
727
+
728
+ .calendar-event-pill {
729
+ display: flex;
730
+ align-items: center;
731
+ gap: var(--space-2xs);
732
+ padding: var(--calendar-event-padding);
733
+ border: none;
734
+ border-radius: var(--calendar-event-radius);
735
+ cursor: pointer;
736
+ text-align: left;
737
+ width: 100%;
738
+ min-height: 0;
739
+ overflow: hidden;
740
+ transition: opacity var(--duration-instant) var(--easing-default);
741
+ }
742
+
743
+ .calendar-event-pill:hover {
744
+ opacity: 0.85;
745
+ }
746
+
747
+ .calendar-event-pill-title {
748
+ font-family: var(--calendar-event-font);
749
+ font-size: var(--calendar-event-size);
750
+ font-weight: var(--calendar-event-weight);
751
+ white-space: nowrap;
752
+ overflow: hidden;
753
+ text-overflow: ellipsis;
754
+ min-width: 0;
755
+ }
756
+
757
+ .calendar-overflow {
758
+ font-family: var(--calendar-overflow-font);
759
+ font-size: var(--calendar-overflow-size);
760
+ color: var(--calendar-overflow-color);
761
+ padding-left: var(--space-xs);
762
+ }
763
+
764
+ /* ═══ Week View ═══ */
765
+ .calendar-week {
766
+ display: flex;
767
+ flex-direction: column;
768
+ flex: 1;
769
+ min-height: 0;
770
+ }
771
+
772
+ .calendar-week-header {
773
+ display: grid;
774
+ grid-template-columns: var(--calendar-time-width) repeat(7, 1fr);
775
+ border-bottom: var(--calendar-cell-border);
776
+ }
777
+
778
+ .calendar-time-spacer {
779
+ width: var(--calendar-time-width);
780
+ }
781
+
782
+ .calendar-week-day-header {
783
+ display: flex;
784
+ flex-direction: column;
785
+ align-items: center;
786
+ gap: var(--space-2xs);
787
+ padding: var(--space-xs) 0;
788
+ }
789
+
790
+ .calendar-week-day-name {
791
+ font-family: var(--calendar-weekday-font);
792
+ font-size: var(--calendar-weekday-size);
793
+ letter-spacing: var(--calendar-weekday-tracking);
794
+ color: var(--calendar-weekday-color);
795
+ text-transform: uppercase;
796
+ }
797
+
798
+ .calendar-week-day-num {
799
+ font-family: var(--calendar-day-font);
800
+ font-size: var(--type-heading-lg-size);
801
+ color: var(--calendar-day-color);
802
+ line-height: 1;
803
+ }
804
+
805
+ .calendar-week-day-today .calendar-week-day-name {
806
+ color: var(--color-accent);
807
+ }
808
+
809
+ .calendar-week-day-num-today {
810
+ color: var(--color-accent);
811
+ }
812
+
813
+ .calendar-week-body {
814
+ display: grid;
815
+ grid-template-columns: var(--calendar-time-width) repeat(7, 1fr);
816
+ flex: 1;
817
+ overflow-y: auto;
818
+ min-height: 0;
819
+ }
820
+
821
+ /* ─── Time gutter ─── */
822
+ .calendar-time-gutter {
823
+ position: relative;
824
+ }
825
+
826
+ .calendar-time-label {
827
+ height: var(--calendar-slot-height);
828
+ display: flex;
829
+ align-items: flex-start;
830
+ justify-content: flex-end;
831
+ padding-right: var(--space-sm);
832
+ font-family: var(--calendar-time-font);
833
+ font-size: var(--calendar-time-size);
834
+ color: var(--calendar-time-color);
835
+ transform: translateY(-50%);
836
+ }
837
+
838
+ .calendar-time-label:first-child {
839
+ visibility: hidden;
840
+ }
841
+
842
+ /* ─── Day columns ─── */
843
+ .calendar-day-column {
844
+ position: relative;
845
+ border-left: var(--calendar-cell-border);
846
+ cursor: pointer;
847
+ }
848
+
849
+ .calendar-hour-slot {
850
+ height: var(--calendar-slot-height);
851
+ border-bottom: var(--calendar-slot-border);
852
+ }
853
+
854
+ /* ─── Now indicator ─── */
855
+ .calendar-now-line {
856
+ position: absolute;
857
+ left: 0;
858
+ right: 0;
859
+ border-top: var(--calendar-now-width) solid var(--calendar-now-color);
860
+ z-index: 2;
861
+ pointer-events: none;
862
+ }
863
+
864
+ .calendar-now-dot {
865
+ position: absolute;
866
+ top: 50%;
867
+ left: 0;
868
+ transform: translate(-50%, -50%);
869
+ width: var(--space-sm);
870
+ height: var(--space-sm);
871
+ background: var(--calendar-now-color);
872
+ border-radius: var(--radius-circle);
873
+ }
874
+
875
+ /* ─── Event blocks (week/day) ─── */
876
+ .calendar-event-block {
877
+ position: absolute;
878
+ display: flex;
879
+ flex-direction: column;
880
+ gap: var(--space-2xs);
881
+ padding: var(--calendar-event-padding);
882
+ border-radius: var(--calendar-event-radius);
883
+ border: none;
884
+ cursor: pointer;
885
+ text-align: left;
886
+ overflow: hidden;
887
+ z-index: 1;
888
+ transition: opacity var(--duration-instant) var(--easing-default);
889
+ }
890
+
891
+ .calendar-event-block:hover {
892
+ opacity: 0.85;
893
+ z-index: 3;
894
+ }
895
+
896
+ .calendar-event-time {
897
+ font-family: var(--calendar-event-font);
898
+ font-size: var(--calendar-event-size);
899
+ opacity: 0.8;
900
+ white-space: nowrap;
901
+ }
902
+
903
+ .calendar-event-title {
904
+ font-family: var(--calendar-event-font);
905
+ font-size: var(--calendar-event-size);
906
+ font-weight: var(--calendar-event-weight);
907
+ white-space: nowrap;
908
+ overflow: hidden;
909
+ text-overflow: ellipsis;
910
+ }
911
+
912
+ /* ═══ Day View ═══ */
913
+ .calendar-day-view {
914
+ display: flex;
915
+ flex-direction: column;
916
+ flex: 1;
917
+ min-height: 0;
918
+ }
919
+
920
+ .calendar-day-header {
921
+ display: grid;
922
+ grid-template-columns: var(--calendar-time-width) 1fr;
923
+ border-bottom: var(--calendar-cell-border);
924
+ }
925
+
926
+ .calendar-day-header-cell {
927
+ display: flex;
928
+ flex-direction: column;
929
+ align-items: center;
930
+ gap: var(--space-2xs);
931
+ padding: var(--space-xs) 0;
932
+ }
933
+
934
+ .calendar-day-body {
935
+ display: grid;
936
+ grid-template-columns: var(--calendar-time-width) 1fr;
937
+ flex: 1;
938
+ overflow-y: auto;
939
+ min-height: 0;
940
+ }
941
+
942
+ /* ═══ Focus ═══ */
943
+ .calendar-nav-btn:focus-visible,
944
+ .calendar-today-btn:focus-visible,
945
+ .calendar-toggle-btn:focus-visible,
946
+ .calendar-event-pill:focus-visible,
947
+ .calendar-event-block:focus-visible {
948
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
949
+ outline-offset: var(--focus-ring-offset);
950
+ }
951
+
952
+ .calendar-nav-btn:focus:not(:focus-visible),
953
+ .calendar-today-btn:focus:not(:focus-visible),
954
+ .calendar-toggle-btn:focus:not(:focus-visible),
955
+ .calendar-event-pill:focus:not(:focus-visible),
956
+ .calendar-event-block:focus:not(:focus-visible) {
957
+ outline: none;
958
+ }
959
+
960
+ /* ═══ Reduced motion ═══ */
961
+ @media (prefers-reduced-motion: reduce) {
962
+ .calendar-nav-btn,
963
+ .calendar-today-btn,
964
+ .calendar-toggle-btn,
965
+ .calendar-cell,
966
+ .calendar-event-pill,
967
+ .calendar-event-block {
968
+ transition: none;
969
+ }
970
+ }
971
+ </style>