feat(calendar): fold calendar lists and show options on hover in the sidebar

The calendar list in the sidebar now works like Google Calendar's:
- "My calendars" and a new "Other calendars" section (shared
  calendars, still grouped by owner) fold away, and stay folded
- each calendar has a checkbox filled in its colour with a check mark
- hovering a calendar shows an options button that opens the same
  menu as a right-click

In the mini month, today is the filled circle and the selected day is
tinted, so the two no longer look alike.
This commit is contained in:
Linus Rath
2026-10-10 22:34:35 +02:00
parent beda42a500
commit 90b1db0432
29 changed files with 184 additions and 67 deletions
+96 -36
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
import { Check, ChevronDown, ChevronRight, Globe, ListTodo, MoreVertical, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -17,6 +17,7 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu
import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { displayNow } from "@/lib/timezone";
import { readableTextOn } from "@/lib/color-transform";
/**
* Split a per-account calendar list into "owned" (the user's own) and
@@ -243,26 +244,33 @@ export function CalendarSidebarPanel({
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
return (
<div key={cal.id} className="relative">
<div key={cal.id} className="relative group/cal">
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
role="checkbox"
aria-checked={isVisible}
data-testid="calendar-item"
data-calendar-name={cal.name}
data-account={cal.accountName ?? ''}
data-visible={isVisible}
className={cn(
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
"hover:bg-muted"
"hover:bg-muted",
hasMenu && "pe-7"
)}
>
{/* A checkbox filled in the calendar colour, empty when hidden. */}
<span
className={cn(
"w-3 h-3 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 flex items-center justify-center transition-colors",
!isVisible && "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
style={isVisible ? { backgroundColor: color, borderColor: color, color: readableTextOn(color) } : undefined}
aria-hidden="true"
>
{isVisible && <Check className="w-2.5 h-2.5" stroke={3.5} />}
</span>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
@@ -284,6 +292,21 @@ export function CalendarSidebarPanel({
/>
)}
</button>
{hasMenu && (
<button
type="button"
onClick={(e) => openContextMenu(e, cal)}
className={cn(
"absolute end-1 top-1/2 -translate-y-1/2 p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-opacity",
"opacity-0 group-hover/cal:opacity-100 focus-visible:opacity-100",
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
)}
aria-label={t('calendar_options', { name: cal.name })}
title={t('calendar_options', { name: cal.name })}
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
@@ -396,6 +419,51 @@ export function CalendarSidebarPanel({
);
};
// "My calendars" / "Other calendars" fold away like the account groups,
// and share their remembered state.
const renderSectionHeader = (key: string, label: string, onAdd?: () => void) => {
const expanded = !collapsedAccountGroups.has(key);
return (
<div className="group flex items-center gap-1 mb-1">
<button
type="button"
onClick={() => toggleAccountGroup(key)}
aria-expanded={expanded}
className="flex-1 min-w-0 flex items-center gap-1 px-1 py-1 rounded-sm text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span className="truncate">{label}</span>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 ms-auto flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 ms-auto flex-shrink-0 rtl:rotate-180" />
)}
</button>
{onAdd && (
<button
type="button"
onClick={onAdd}
className="p-0.5 rounded text-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground hover:bg-muted transition-opacity"
title={tMgmt('add_calendar')}
aria-label={tMgmt('add_calendar')}
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
const renderSection = (key: string, label: string, list: Calendar[], onAdd?: () => void) => (
<div>
{renderSectionHeader(key, label, onAdd)}
{!collapsedAccountGroups.has(key) && (
<div className="space-y-0.5">
{list.map(renderCalendarItem)}
</div>
)}
</div>
);
return (
<div className="mt-4">
{enableCalendarTasks && (
@@ -484,37 +552,29 @@ export function CalendarSidebarPanel({
</>
) : (
<>
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
{renderSection(
'__mine__',
t('my_calendars'),
personalCalendars,
onCreateCalendar,
)}
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.length > 0 && (
<div className="mt-4">
{renderSectionHeader('__others__', t('other_calendars'))}
{!collapsedAccountGroups.has('__others__') && sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-2">
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{group.accountName}
</div>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</div>
))}
)}
</>
)}
+7 -4
View File
@@ -187,13 +187,16 @@ export function MiniCalendar({
className={cn(
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
!inMonth && "text-muted-foreground/40",
inMonth && !selected && "hover:bg-muted",
today && !selected && "font-bold text-primary",
selected && "bg-primary text-primary-foreground"
// Today is the filled circle; the selected day is tinted.
today && "bg-primary text-primary-foreground font-semibold",
selected && !today && "bg-primary/15 text-primary font-semibold",
!today && !selected && "hover:bg-muted"
)}
aria-current={today ? "date" : undefined}
aria-pressed={selected}
>
{formatDayNumber(day)}
{hasEvent && !selected && (
{hasEvent && !selected && !today && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
</button>
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "إجراءات أخرى",
"select_view": "تغيير العرض، الحالي: {view}"
}
},
"other_calendars": "تقويمات أخرى",
"calendar_options": "خيارات {name}"
},
"calendar_event_notifications": {
"someone": "شخص ما",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Més accions",
"select_view": "Canvia la vista, actualment {view}"
}
},
"other_calendars": "Altres calendaris",
"calendar_options": "Opcions de {name}"
},
"calendar_event_notifications": {
"someone": "Algú",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Další akce",
"select_view": "Změnit zobrazení, nyní {view}"
}
},
"other_calendars": "Další kalendáře",
"calendar_options": "Možnosti pro {name}"
},
"advanced_search": {
"title": "Pokročilé hledání",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Flere handlinger",
"select_view": "Skift visning, aktuelt {view}"
}
},
"other_calendars": "Andre kalendere",
"calendar_options": "Indstillinger for {name}"
},
"calendar_event_notifications": {
"someone": "Nogen",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Weitere Aktionen",
"select_view": "Ansicht wechseln, aktuell {view}"
}
},
"other_calendars": "Weitere Kalender",
"calendar_options": "Optionen für {name}"
},
"advanced_search": {
"title": "Erweiterte Suche",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "More actions",
"select_view": "Change view, currently {view}"
}
},
"other_calendars": "Other calendars",
"calendar_options": "Options for {name}"
},
"calendar_event_notifications": {
"someone": "Someone",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Más acciones",
"select_view": "Cambiar vista, actualmente {view}"
}
},
"other_calendars": "Otros calendarios",
"calendar_options": "Opciones de {name}"
},
"advanced_search": {
"title": "Búsqueda avanzada",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "اقدامات بیشتر",
"select_view": "تغییر نما، نمای فعلی: {view}"
}
},
"other_calendars": "تقویم‌های دیگر",
"calendar_options": "گزینه‌های {name}"
},
"calendar_event_notifications": {
"someone": "کسی",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Plus d'actions",
"select_view": "Changer de vue, actuellement {view}"
}
},
"other_calendars": "Autres agendas",
"calendar_options": "Options de {name}"
},
"advanced_search": {
"title": "Recherche avancée",
+3 -1
View File
@@ -3364,7 +3364,9 @@
"toolbar": {
"more": "פעולות נוספות",
"select_view": "החלפת תצוגה, כעת {view}"
}
},
"other_calendars": "יומנים אחרים",
"calendar_options": "אפשרויות עבור {name}"
},
"advanced_search": {
"title": "חיפוש מתקדם",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "További műveletek",
"select_view": "Nézet váltása, jelenleg {view}"
}
},
"other_calendars": "Egyéb naptárak",
"calendar_options": "{name} beállításai"
},
"calendar_event_notifications": {
"someone": "Valaki",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Altre azioni",
"select_view": "Cambia vista, attualmente {view}"
}
},
"other_calendars": "Altri calendari",
"calendar_options": "Opzioni per {name}"
},
"advanced_search": {
"title": "Ricerca avanzata",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "その他の操作",
"select_view": "表示を切り替え(現在: {view})"
}
},
"other_calendars": "他のカレンダー",
"calendar_options": "{name} のオプション"
},
"advanced_search": {
"title": "詳細検索",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "추가 작업",
"select_view": "보기 변경, 현재 {view}"
}
},
"other_calendars": "다른 캘린더",
"calendar_options": "{name} 옵션"
},
"advanced_search": {
"title": "상세 검색",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Citas darbības",
"select_view": "Mainīt skatu, pašlaik {view}"
}
},
"other_calendars": "Citi kalendāri",
"calendar_options": "{name} opcijas"
},
"advanced_search": {
"title": "Izvērstā meklēšana",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Бусад үйлдэл",
"select_view": "Харагдац солих, одоо {view}"
}
},
"other_calendars": "Бусад хуанли",
"calendar_options": "{name}-ийн тохиргоо"
},
"calendar_event_notifications": {
"someone": "Хэн нэгэн",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Flere handlinger",
"select_view": "Bytt visning, nå {view}"
}
},
"other_calendars": "Andre kalendere",
"calendar_options": "Alternativer for {name}"
},
"calendar_event_notifications": {
"someone": "Noen",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Meer acties",
"select_view": "Weergave wijzigen, nu {view}"
}
},
"other_calendars": "Andere agenda's",
"calendar_options": "Opties voor {name}"
},
"advanced_search": {
"title": "Geavanceerd zoeken",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Więcej działań",
"select_view": "Zmień widok, obecnie {view}"
}
},
"other_calendars": "Inne kalendarze",
"calendar_options": "Opcje dla {name}"
},
"advanced_search": {
"title": "Wyszukiwanie zaawansowane",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Mais ações",
"select_view": "Mudar vista, atualmente {view}"
}
},
"other_calendars": "Outros calendários",
"calendar_options": "Opções de {name}"
},
"advanced_search": {
"title": "Pesquisa avançada",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Mai multe acțiuni",
"select_view": "Schimbă vizualizarea, acum {view}"
}
},
"other_calendars": "Alte calendare",
"calendar_options": "Opțiuni pentru {name}"
},
"calendar_event_notifications": {
"someone": "Cineva",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Другие действия",
"select_view": "Сменить вид, сейчас {view}"
}
},
"other_calendars": "Другие календари",
"calendar_options": "Параметры: {name}"
},
"advanced_search": {
"title": "Расширенный поиск",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Ďalšie akcie",
"select_view": "Zmeniť zobrazenie, teraz {view}"
}
},
"other_calendars": "Ďalšie kalendáre",
"calendar_options": "Možnosti pre {name}"
},
"calendar_event_notifications": {
"someone": "Niekto",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Diğer işlemler",
"select_view": "Görünümü değiştir, şu an {view}"
}
},
"other_calendars": "Diğer takvimler",
"calendar_options": "{name} seçenekleri"
},
"calendar_event_notifications": {
"someone": "Birisi",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "Інші дії",
"select_view": "Змінити вигляд, зараз {view}"
}
},
"other_calendars": "Інші календарі",
"calendar_options": "Параметри: {name}"
},
"advanced_search": {
"title": "Розширений пошук",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "更多動作",
"select_view": "切換檢視,目前為{view}"
}
},
"other_calendars": "其他日曆",
"calendar_options": "{name} 的選項"
},
"calendar_event_notifications": {
"someone": "有人",
+3 -1
View File
@@ -3440,7 +3440,9 @@
"toolbar": {
"more": "更多操作",
"select_view": "切换视图,当前为{view}"
}
},
"other_calendars": "其他日历",
"calendar_options": "{name} 的选项"
},
"advanced_search": {
"title": "高级搜索",