feat(calendar): hover tooltip, light theme grid, UX polish
Deploy / verify (push) Successful in 53s
Deploy / deploy (push) Successful in 55s

- Hover tooltip on event cells (pointer:fine only, position:fixed bypasses overflow-hidden)
  — shows title + right-aligned time, max 3 events, "+N weitere"
- Grid background changed to himmel-100 (light), all text tokens updated to dark
- Today cell: wald-200 green background + wald-400 outline
- Empty day cells: subtle bg-himmel-50/40 tint
- Event badge larger on desktop (sm:min-w-4.5 sm:h-4.5 sm:text-[10px])
- Weekday headers: font-semibold text-himmel-800
- Calendar takes full width inside widget (removed max-w-2xl cap)
- Web Share API per event (navigator.share / clipboard fallback, Kopiert\! toast)
- Subscribe bar: webcal:// on Apple, direct ICS download on other platforms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-12 17:23:41 +02:00
parent 6fb2592eae
commit 6fc2c0a345
+131 -14
View File
@@ -405,6 +405,24 @@
t(T.calendar_weekday_so),
]);
let hasHover = $state(false);
let tooltipDay = $state<string | null>(null);
let tooltipPos = $state({ x: 0, y: 0 });
function showTooltip(e: MouseEvent, key: string) {
if (!hasHover) return;
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
tooltipPos = { x: r.left + r.width / 2, y: r.top };
tooltipDay = key;
}
function hideTooltip() {
tooltipDay = null;
}
const tooltipEvents = $derived(
tooltipDay ? (eventsByDay.get(tooltipDay) ?? []) : [],
);
let feedHost = $state("");
const webcalUrl = $derived(
feedHost ? `webcal://${feedHost}/api/kalender.ics` : "#",
@@ -474,6 +492,9 @@
onMount(() => {
feedHost = window.location.host;
hasHover = window.matchMedia(
"(hover: hover) and (pointer: fine)",
).matches;
tickTimer = setInterval(() => {
nowMs = Date.now();
}, 60_000);
@@ -852,13 +873,13 @@
Tag-Grid. Klick auf Tag filtert Liste unten. -->
<div
bind:this={widgetEl}
class="calendar-widget bg-himmel-800 text-himmel-100 p-4"
class="calendar-widget bg-himmel-100 text-himmel-900 p-4"
>
<div class="mx-auto max-w-2xl">
<div class="w-full">
<div class="flex items-center justify-between gap-2 mb-4">
<button
type="button"
class="p-2 rounded-xs text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_prev_month)}
onclick={prevMonth}
>
@@ -869,12 +890,12 @@
/>
</button>
<span
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
class="text-sm font-medium text-himmel-900 capitalize whitespace-nowrap"
>{monthLabel}</span
>
<button
type="button"
class="p-2 rounded-xs text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
class="p-2 rounded-xs text-himmel-800 hover:bg-himmel-200 hover:text-himmel-900 transition-colors"
aria-label={t(T.calendar_next_month)}
onclick={nextMonth}
>
@@ -889,7 +910,7 @@
<div class="grid grid-cols-7 gap-1.5 text-center text-sm">
{#each weekdays as w}
<div
class="py-1.5 text-xs font-medium text-himmel-100 opacity-80"
class="py-1.5 text-xs font-semibold text-himmel-800"
>
{w}
</div>
@@ -909,11 +930,13 @@
<button
type="button"
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
? 'text-himmel-100 hover:bg-himmel-700'
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
? 'ring-2 ring-himmel-400 bg-himmel-600'
? 'text-himmel-900 hover:bg-himmel-200'
: 'text-himmel-700 opacity-60 hover:opacity-80'} bg-himmel-200/70 font-semibold hover:bg-himmel-300 {isSelected
? 'ring-2 ring-himmel-700 bg-himmel-300'
: ''} {isToday ? 'today-cell' : ''}"
onclick={() => selectDay(key)}
onmouseenter={(e) => showTooltip(e, key)}
onmouseleave={hideTooltip}
aria-pressed={isSelected}
aria-current={isToday ? "date" : undefined}
aria-label="{d.toLocaleDateString('de-DE', {
@@ -928,7 +951,7 @@
>
<span class="text-xs">{d.getDate()}</span>
<span
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 sm:min-w-4.5 sm:h-4.5 px-0.5 rounded-full text-stein-0 text-[9px] sm:text-[10px] font-medium leading-none {isFuture
? 'bg-wald-400'
: 'bg-error'}"
aria-hidden="true"
@@ -938,9 +961,9 @@
</button>
{:else}
<div
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
? 'text-himmel-100'
: 'text-himmel-100 opacity-50'} {isToday
class="relative h-10 rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default bg-himmel-50/40 {isCurrentMonth
? 'text-himmel-800'
: 'text-himmel-600 opacity-50'} {isToday
? 'today-cell'
: ''}"
aria-current={isToday ? "date" : undefined}
@@ -959,7 +982,29 @@
</div>
</div>
<!-- Events: voll Breite, unten. Akkordeon-Liste, in natürlichem
<!-- Hover-Tooltip: fixed-positioned → nicht durch overflow-hidden geclipt.
Nur auf pointer:fine Geräten (hasHover). Zeigt max 3 Events des Tages. -->
{#if hasHover && tooltipDay && tooltipEvents.length > 0}
<div
class="cal-tooltip"
style="left:{tooltipPos.x}px; top:{tooltipPos.y}px;"
aria-hidden="true"
>
{#each tooltipEvents.slice(0, 3) as ev}
<div class="cal-tooltip-row">
<span class="cal-tooltip-title">{ev.title || t(T.calendar_untitled)}</span>
{#if ev.timeStr}
<span class="cal-tooltip-time">{ev.timeStr}</span>
{/if}
</div>
{/each}
{#if tooltipEvents.length > 3}
<div class="cal-tooltip-more">+{tooltipEvents.length - 3} weitere</div>
{/if}
</div>
{/if}
<!-- Events: voll Breite, unten. Akkordeon-Liste, in natürlichem
Page-Flow (kein internes Scrollen). Kalender-Klick filtert auf
einen Tag; ohne Filter Liste ab heute, gruppiert nach Tag. -->
<div class="calendar-events-panel bg-stein-0 border-t border-stein-200">
@@ -1098,6 +1143,7 @@
.calendar-block .today-cell {
outline: 2px solid var(--color-wald-400, #84e0a4);
outline-offset: -2px;
background-color: color-mix(in srgb, var(--color-wald-200, #bbf0cf) 40%, transparent);
}
/* Event-Rows visuell trennen: Card-Optik mit Urgency-Akzent links,
Zebra-Stripe ab dem zweiten Eintrag, deutlicher Separator
@@ -1151,6 +1197,77 @@
.calendar-block .day-group + .day-group {
border-top: 4px solid var(--color-stein-100, #ede8df);
}
/* Hover-Tooltip: fixed über dem Grid, zeigt Event-Vorschau auf hover.
pointer-events:none damit kein mouseenter/leave Loop entsteht. */
:global(.cal-tooltip) {
position: fixed;
z-index: 9999;
transform: translateX(-50%) translateY(calc(-100% - 8px));
background: #fff;
color: var(--color-stein-900, #1c1a17);
border: 1px solid var(--color-stein-200, #e0dcd5);
border-radius: 4px;
padding: 6px 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
font-size: 11px;
line-height: 1.4;
pointer-events: none;
min-width: 130px;
max-width: 220px;
}
:global(.cal-tooltip::after) {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--color-stein-200, #e0dcd5);
}
:global(.cal-tooltip::before) {
content: "";
position: absolute;
top: calc(100% - 1px);
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #fff;
z-index: 1;
}
:global(.cal-tooltip-row) {
display: flex;
align-items: baseline;
gap: 4px;
padding: 1px 0;
}
:global(.cal-tooltip-row + .cal-tooltip-row) {
border-top: 1px solid var(--color-stein-100, #ede8df);
margin-top: 2px;
padding-top: 3px;
}
:global(.cal-tooltip-title) {
font-weight: 500;
color: var(--color-stein-900, #1c1a17);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
:global(.cal-tooltip-time) {
font-size: 10px;
color: var(--color-stein-500, #7a7469);
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
}
:global(.cal-tooltip-more) {
font-size: 10px;
color: var(--color-stein-500, #7a7469);
margin-top: 3px;
border-top: 1px solid var(--color-stein-100, #ede8df);
padding-top: 3px;
}
/* Print: alle <details> geöffnet, Chevrons aus, Day-Groups nicht
splitten. */
@media print {