feat(calendar): Kalenderwahl wieder als Ausklappliste, weiterhin mit Farbe

Die Plaketten aus dem vorigen Stand nahmen bei mehreren Kalendern viel
Platz im Formular und liefen ueber mehrere Zeilen um. Also zurueck zur
Ausklappliste -- aber nicht zurueck zum reinen Text.

Ein natives <select> kam dafuer nicht in Frage: Dessen Eintraege lassen
sich in keinem Browser verlaesslich einfaerben, und im zugeklappten
Zustand schon gar nicht. Deshalb eine eigene Liste, die im Ruhezustand
aussieht wie die anderen Felder daneben, aber den Farbpunkt des
Kalenders traegt -- gefuellt fuer den gewaehlten, leerer Ring fuer die
anderen, dieselbe Sprache wie in der Kalenderliste.

Eine Falle dabei: Escape muss beim Schliessen der Liste angehalten
werden. Sonst laeuft es weiter bis zum Fenster, wo der Termindialog
lauscht, und macht gleich das ganze Formular zu -- mitsamt allem, was
schon eingetippt war.

Selbsttest weiter 36 gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-05 07:50:19 +02:00
1 parent 7a1f0837f5
commit cc35161e74
2 files changed
+84 -40

No files matched your search

@@ -5,7 +5,7 @@ import {
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO, addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes, toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
} from './dates' } from './dates'
import { CalendarPicker, ColorSwatches, Toggle } from './controls' import { CalendarSelect, ColorSwatches, Toggle } from './controls'
interface Props { interface Props {
/** Fehlt er, entsteht ein neuer Termin. */ /** Fehlt er, entsteht ein neuer Termin. */
@@ -215,7 +215,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
Speichern die Sammlung, behält aber seine Kennung – für Speichern die Sammlung, behält aber seine Kennung – für
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
neuer. */} neuer. */}
<CalendarPicker calendars={calendars} value={calId} onChange={setCalId} disabled={readOnly} /> <CalendarSelect calendars={calendars} value={calId} onChange={setCalId} disabled={readOnly} />
{!!event && calId !== calendarId && ( {!!event && calId !== calendarId && (
<p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}> <p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben
+82 -38
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react' import { useEffect, useRef, useState, type CSSProperties } from 'react'
/** Eine Palette für Kalender **und** Termine – zwei getrennte Listen liefen /** Eine Palette für Kalender **und** Termine – zwei getrennte Listen liefen
* sonst mit der Zeit auseinander, und ein Termin in einer Farbe, die es als * sonst mit der Zeit auseinander, und ein Termin in einer Farbe, die es als
@@ -95,55 +95,99 @@ export function ColorSwatches({ value, onChange, inherit, disabled }: {
} }
/** /**
* Kalenderwahl als farbige Plaketten statt als Auswahlfeld. * Kalenderwahl als Ausklappliste – mit dem Farbpunkt des Kalenders.
* *
* Ein Auswahlfeld zeigt nur den Namen – die Farbe aber ist das, woran man * Bewusst kein natives `<select>`: Dessen Einträge lassen sich in keinem
* einen Kalender im Raster wiedererkennt. Wer „Arbeit" wählt, soll dabei * Browser verlässlich einfärben, und ausgerechnet die Farbe ist das, woran
* man einen Kalender im Raster wiedererkennt. Wer „Arbeit" wählt, soll dabei
* schon das Blau sehen, in dem der Termin gleich erscheint. * schon das Blau sehen, in dem der Termin gleich erscheint.
* *
* Der Punkt spricht dieselbe Sprache wie überall sonst im Kalender: gefüllt * Der Punkt spricht dieselbe Sprache wie überall sonst im Kalender: gefüllt
* heißt gewählt, ein leerer Ring heißt nicht gewählt. * heißt gewählt, ein leerer Ring heißt nicht gewählt.
*/ */
export function CalendarPicker({ calendars, value, onChange, disabled }: { export function CalendarSelect({ calendars, value, onChange, disabled }: {
calendars: { id: string; name: string; color: string }[] calendars: { id: string; name: string; color: string }[]
value: string value: string
onChange: (id: string) => void onChange: (id: string) => void
disabled?: boolean disabled?: boolean
}) { }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const current = calendars.find(c => c.id === value)
useEffect(() => {
if (!open) return
function onDoc(e: globalThis.MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
const field: CSSProperties = {
width: '100%', boxSizing: 'border-box', display: 'flex', alignItems: 'center', gap: 9,
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1, textAlign: 'left',
}
const dot = (color: string, filled: boolean) => (
<span style={{
width: 12, height: 12, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${color}`, background: filled ? color : 'transparent',
}} />
)
return ( return (
<div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}> <div
{calendars.map(cal => { ref={ref}
const on = cal.id === value style={{ position: 'relative' }}
return ( // Escape schließt nur die Liste. Ohne das Anhalten liefe es weiter bis
<button // zum Fenster und machte gleich den ganzen Termindialog zu – mitsamt
key={cal.id} type="button" disabled={disabled} // allem, was schon eingetippt war.
onClick={() => onChange(cal.id)} onKeyDown={e => { if (open && e.key === 'Escape') { e.stopPropagation(); setOpen(false) } }}
aria-pressed={on} >
style={{ <button type="button" disabled={disabled} onClick={() => setOpen(o => !o)}
display: 'flex', alignItems: 'center', gap: 7, maxWidth: '100%', aria-haspopup="listbox" aria-expanded={open} style={field}>
padding: '7px 12px 7px 10px', borderRadius: 999, fontSize: 13, {current && dot(current.color, true)}
border: `1.5px solid ${on ? cal.color : 'var(--color-border)'}`, <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
// Nur ein Hauch der Kalenderfarbe: Ausgefüllte Plaketten in acht {current?.name ?? 'Kalender wählen'}
// kräftigen Tönen nebeneinander erschlagen das ganze Formular. </span>
background: on ? cal.color + '22' : 'transparent', <span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
color: on ? 'var(--color-text)' : 'var(--color-text-muted)', </button>
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1, {open && !disabled && (
transition: 'background 0.12s, border-color 0.12s, color 0.12s', <div role="listbox" style={{
} as CSSProperties} position: 'absolute', left: 0, right: 0, top: 'calc(100% + 4px)', zIndex: 40,
> background: 'var(--color-surface)', border: '1px solid var(--color-border)',
<span style={{ borderRadius: 8, boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
width: 12, height: 12, borderRadius: '50%', flexShrink: 0, maxHeight: 220, overflowY: 'auto',
border: `2px solid ${cal.color}`, }}>
background: on ? cal.color : 'transparent', {calendars.map(cal => {
transition: 'background 0.12s', const on = cal.id === value
}} /> return (
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <button
{cal.name} key={cal.id} type="button" role="option" aria-selected={on}
</span> onClick={() => { onChange(cal.id); setOpen(false) }}
</button> className="aether-cal-row"
) style={{
})} display: 'flex', alignItems: 'center', gap: 9, width: '100%',
padding: '9px 10px', border: 'none', textAlign: 'left', cursor: 'pointer',
fontSize: 13.5, color: 'var(--color-text)',
// Nur ein Hauch der Kalenderfarbe für den gewählten Eintrag:
// voll ausgefüllt schriee die Liste den Nebendarsteller aus.
background: on ? cal.color + '22' : 'transparent',
}}
>
{dot(cal.color, on)}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cal.name}
</span>
</button>
)
})}
</div>
)}
</div> </div>
) )
} }