feat(calendar): match the calendar layout to the rest of the app

The calendar bar spanned both the sidebar and the grid, and the sidebar
opened with a full-width primary "Create event" button. Neither exists
in mail or contacts, so the calendar looked like a different app.

- the sidebar runs full height with a title row and a "+" button, like
  the contacts sidebar
- the view bar sits over the grid only and uses the density header
  padding, so its border lines up with the sidebar header
- calendar rows and section headers follow the contacts sidebar: full
  width 32px rows, chevron on the left
This commit is contained in:
Linus Rath
2026-10-10 23:46:37 +02:00
parent 6d9bd1e4d0
commit f94f4fc605
4 changed files with 72 additions and 35 deletions
+11 -15
View File
@@ -75,6 +75,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header";
import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
@@ -1774,15 +1775,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Desktop: the bar spans the sidebar and the grid, as in Google Calendar. */}
{!isMobile && calendarToolbar}
<div className="flex flex-1 min-w-0 min-h-0">
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
{(isNarrow || !sidebarCollapsed) && (
<>
<div
className={cn(
"border-e border-border bg-secondary overflow-y-auto overflow-x-hidden flex-shrink-0 p-3",
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -1792,17 +1791,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
)}
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
>
{!isMobile && (
<Button
size="sm"
onClick={() => { setNarrowSidebarOpen(false); openCreateModal(); }}
data-tour={isNarrow ? undefined : "create-event-button"}
className="w-full h-8 mb-3"
>
<Plus className="w-4 h-4 me-1" />
{t("events.create")}
</Button>
)}
<CalendarSidebarHeader
onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateModal(); }}
tourTarget={!isNarrow}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3">
<div className="px-3">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
@@ -1812,6 +1806,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
firstDayOfWeek={firstDayOfWeek}
showWeekNumbers={showWeekNumbers}
/>
</div>
<CalendarSidebarPanel
calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds}
@@ -1878,6 +1873,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
client={client}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
/>
</div>
</div>
{!isNarrow && (
<ResizeHandle
@@ -1894,7 +1890,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
)}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{isMobile && calendarToolbar}
{calendarToolbar}
{/* isolate keeps the views' sticky headers (z-50) below the toolbar's
import dropdown, which overlaps this area (#1049). */}
@@ -0,0 +1,39 @@
"use client";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Plus } from "@/components/icons";
interface CalendarSidebarHeaderProps {
onCreateEvent: () => void;
/** Mark the button for the guided tour; off while the toolbar's own "+" is the visible one. */
tourTarget?: boolean;
}
/**
* Title row at the top of the calendar sidebar, the same shape as the
* contacts sidebar header: the app name on the left and "+" on the right.
*/
export function CalendarSidebarHeader({ onCreateEvent, tourTarget = true }: CalendarSidebarHeaderProps) {
const t = useTranslations("calendar");
return (
<div
className="px-3 border-b border-border flex items-center justify-between flex-shrink-0"
style={{ paddingBlock: "var(--density-header-py)" }}
>
<span className="text-sm font-semibold truncate">{t("title")}</span>
<Button
size="icon"
variant="ghost"
onClick={onCreateEvent}
className="h-8 w-8 flex-shrink-0"
aria-label={t("events.create")}
title={t("events.create")}
data-tour={tourTarget ? "create-event-button" : undefined}
>
<Plus className="w-4 h-4" />
</Button>
</div>
);
}
+21 -19
View File
@@ -255,10 +255,11 @@ export function CalendarSidebarPanel({
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",
"flex items-center gap-2 w-full ps-5 pe-3 text-sm text-foreground/80 transition-colors duration-150",
"hover:bg-muted",
hasMenu && "pe-7"
hasMenu && "pe-9"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
{/* A checkbox filled in the calendar colour, empty when hidden. */}
<span
@@ -297,7 +298,7 @@ export function CalendarSidebarPanel({
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",
"absolute end-2 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"
)}
@@ -424,19 +425,19 @@ export function CalendarSidebarPanel({
const renderSectionHeader = (key: string, label: string, onAdd?: () => void) => {
const expanded = !collapsedAccountGroups.has(key);
return (
<div className="group flex items-center gap-1 mb-1">
<div className="group flex items-center gap-1 pe-3">
<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"
className="flex-1 min-w-0 flex items-center gap-1 ps-3 py-1 text-start"
>
<span className="truncate">{label}</span>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 ms-auto flex-shrink-0" />
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 ms-auto flex-shrink-0 rtl:rotate-180" />
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0 rtl:rotate-180" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">{label}</span>
</button>
{onAdd && (
<button
@@ -457,7 +458,7 @@ export function CalendarSidebarPanel({
<div>
{renderSectionHeader(key, label, onAdd)}
{!collapsedAccountGroups.has(key) && (
<div className="space-y-0.5">
<div>
{list.map(renderCalendarItem)}
</div>
)}
@@ -469,7 +470,8 @@ export function CalendarSidebarPanel({
{enableCalendarTasks && (
<button
onClick={() => setViewMode('tasks')}
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
className="flex items-center gap-2 w-full px-3 mb-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<ListTodo className="w-4 h-4 text-muted-foreground" />
<span>{t('tasks.label')}</span>
@@ -491,7 +493,7 @@ export function CalendarSidebarPanel({
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
<button
onClick={() => toggleAccountGroup(group.key)}
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
className="group w-full flex items-center gap-1.5 px-3 py-1 hover:bg-muted/40 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
@@ -522,24 +524,24 @@ export function CalendarSidebarPanel({
)}
</button>
{expanded && (
<div className="mt-1 ps-3">
<div className="mt-1">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t('my_calendars')}
</div>
<div className="space-y-0.5">
<div>
{owned.map(renderCalendarItem)}
</div>
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} 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">
<div className="ps-5 pe-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
<div className="space-y-0.5">
<div>
{sg.calendars.map(renderCalendarItem)}
</div>
</div>
@@ -560,15 +562,15 @@ export function CalendarSidebarPanel({
)}
{sharedAccountGroups.length > 0 && (
<div className="mt-4">
<div className="mt-2">
{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">
<div className="ps-5 pe-3 py-0.5 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">
<div>
{group.calendars.map(renderCalendarItem)}
</div>
</div>
+1 -1
View File
@@ -332,7 +332,7 @@ export function CalendarToolbar({
const currentViewLabel = t(`views.${viewMode}`);
return (
<div className="flex items-center gap-1 px-4 py-3 border-b border-border">
<div className="flex items-center gap-1 px-4 border-b border-border" style={{ paddingBlock: "var(--density-header-py)" }}>
{onMenuClick && (
<Button
variant="ghost"