mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat(calendar): open a day from the week header and show a clearer now line
The week and day headers now show a small weekday above a large date, with today's date in a filled circle and its weekday in the primary colour. Clicking a date in the week header opens that day in the day view. The all-day row moved below the dates, where it lines up with the columns it belongs to. The current time line is thicker with a larger dot at the start of today's column, and events shorter than 45 minutes show their start time next to the title instead of hiding it.
This commit is contained in:
@@ -566,6 +566,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [setSelectedDate, jumpTo, isMobile, normalizedViewMode, setViewMode]);
|
||||
|
||||
// Clicking a date number in the week or month header opens that day in
|
||||
// the day view, as in Google Calendar.
|
||||
const handleOpenDay = useCallback((date: Date) => {
|
||||
setMobileReturnToMonth(isMobile && normalizedViewMode === "month");
|
||||
jumpTo(date);
|
||||
setSelectedDate(date);
|
||||
setMiniMonth(date);
|
||||
setViewMode("day");
|
||||
setNarrowSidebarOpen(false);
|
||||
}, [jumpTo, setSelectedDate, setViewMode, isMobile, normalizedViewMode]);
|
||||
|
||||
// The mini calendar is a navigation control: unlike a click in the week or
|
||||
// month grid, which marks a day that is already on screen, picking a day
|
||||
// here has to bring that day into view.
|
||||
@@ -1485,6 +1496,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
events={visibleEvents}
|
||||
calendars={allCalendars}
|
||||
onSelectDate={handleSelectDate}
|
||||
onOpenDay={handleOpenDay}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onHoverEvent={handleHoverEvent}
|
||||
onHoverLeave={handleHoverLeave}
|
||||
|
||||
@@ -313,13 +313,26 @@ export function CalendarDayView({
|
||||
const dayTasks = tasksByDay.get(key) ?? [];
|
||||
return (
|
||||
<div key={key} data-day={key} className="min-w-0 border-e border-border last:border-e-0">
|
||||
<div className={cn("px-4 py-3", isMobile && "px-3 py-2")}>
|
||||
<h3 className={cn("font-semibold truncate", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
|
||||
{isMobile
|
||||
? intlFormatter.dateTime(day, { weekday: "short", month: "short", day: "numeric" })
|
||||
: intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||
}
|
||||
</h3>
|
||||
<div
|
||||
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
|
||||
role="columnheader"
|
||||
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-10 text-center text-[11px] font-medium uppercase tracking-wide",
|
||||
today ? "text-primary" : "text-muted-foreground",
|
||||
)}>
|
||||
{intlFormatter.dateTime(day, { weekday: "short" })}
|
||||
</div>
|
||||
<div
|
||||
aria-current={today ? "date" : undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center w-10 h-10 rounded-full text-xl",
|
||||
today ? "bg-primary text-primary-foreground font-semibold" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasAllDayArea && (
|
||||
@@ -539,9 +552,9 @@ export function CalendarDayView({
|
||||
className="absolute left-0 right-0 z-20 pointer-events-none"
|
||||
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
|
||||
<div className="flex-1 h-px bg-destructive" />
|
||||
<div className="flex items-center -translate-y-1/2">
|
||||
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
|
||||
<div className="flex-1 h-0.5 bg-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectDate: (date: Date) => void;
|
||||
/** Open a day in the day view (clicking its date in the header). */
|
||||
onOpenDay?: (date: Date) => void;
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onHoverLeave?: () => void;
|
||||
@@ -63,6 +65,7 @@ export function CalendarWeekView({
|
||||
isLoading = false,
|
||||
onVisibleDateChange,
|
||||
onSelectDate,
|
||||
onOpenDay,
|
||||
onSelectEvent,
|
||||
onHoverEvent,
|
||||
onHoverLeave,
|
||||
@@ -384,6 +387,59 @@ export function CalendarWeekView({
|
||||
<div ref={startSentinelRef} data-testid="week-start-sentinel" className="absolute inset-y-0 start-0 w-px pointer-events-none" />
|
||||
<div ref={endSentinelRef} data-testid="week-end-sentinel" className="absolute inset-y-0 end-0 w-px pointer-events-none" />
|
||||
<div className="sticky top-0 z-50 bg-background">
|
||||
<div className="flex border-b border-border" role="row">
|
||||
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
|
||||
{canToggleHours && (
|
||||
<AllHoursToggle
|
||||
showAllHours={showAllHours}
|
||||
configured={configuredHours}
|
||||
timeFormat={timeFormat}
|
||||
onToggle={toggleAllHours}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-s border-border grid" style={columnsStyle}>
|
||||
{days.map((day) => {
|
||||
const todayCol = isDisplayToday(day);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
role="columnheader"
|
||||
aria-label={fullLabel}
|
||||
data-day={dayKey(day)}
|
||||
className="flex flex-col items-center gap-0.5 pt-2 pb-1.5 border-e border-border last:border-e-0"
|
||||
>
|
||||
<div className={cn(
|
||||
"text-[11px] font-medium uppercase tracking-wide",
|
||||
todayCol ? "text-primary" : "text-muted-foreground",
|
||||
)}>
|
||||
{intlFormatter.dateTime(day, { weekday: "short" })}
|
||||
</div>
|
||||
{/* The date opens that day in the day view. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (onOpenDay ?? onSelectDate)(day)}
|
||||
aria-label={fullLabel}
|
||||
aria-current={todayCol ? "date" : undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full transition-colors touch-manipulation",
|
||||
isMobile ? "w-8 h-8 text-base" : "w-10 h-10 text-xl",
|
||||
todayCol
|
||||
? "bg-primary text-primary-foreground font-semibold hover:bg-primary/90"
|
||||
: selected
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{hasAllDay && (
|
||||
<div className="flex border-b border-border">
|
||||
<div
|
||||
@@ -486,50 +542,6 @@ export function CalendarWeekView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex border-b border-border" role="row">
|
||||
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
|
||||
{canToggleHours && (
|
||||
<AllHoursToggle
|
||||
showAllHours={showAllHours}
|
||||
configured={configuredHours}
|
||||
timeFormat={timeFormat}
|
||||
onToggle={toggleAllHours}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-s border-border grid" style={columnsStyle}>
|
||||
{days.map((day) => {
|
||||
const todayCol = isDisplayToday(day);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => onSelectDate(day)}
|
||||
role="columnheader"
|
||||
aria-label={fullLabel}
|
||||
data-day={dayKey(day)}
|
||||
className={cn(
|
||||
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
|
||||
"hover:bg-muted/50",
|
||||
todayCol && "font-bold",
|
||||
)}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground uppercase">
|
||||
{intlFormatter.dateTime(day, { weekday: "short" })}
|
||||
</div>
|
||||
<div className={cn(
|
||||
"inline-flex items-center justify-center w-7 h-7 rounded-full text-sm",
|
||||
todayCol && "bg-primary text-primary-foreground",
|
||||
selected && !todayCol && "bg-accent text-accent-foreground"
|
||||
)}>
|
||||
{format(day, "d")}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -673,9 +685,9 @@ export function CalendarWeekView({
|
||||
className="absolute left-0 right-0 z-20 pointer-events-none"
|
||||
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
|
||||
<div className="flex-1 h-px bg-destructive" />
|
||||
<div className="flex items-center -translate-y-1/2">
|
||||
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
|
||||
<div className="flex-1 h-0.5 bg-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -218,8 +218,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
)}
|
||||
style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected }), ...style }}
|
||||
>
|
||||
<div className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</div>
|
||||
{/* Shorter blocks have no room for a second line at the 60px hour. */}
|
||||
{/* Shorter blocks have no room for a second line at the 60px hour, so
|
||||
their start time follows the title on the same line. */}
|
||||
<div className="flex items-baseline gap-1 min-w-0">
|
||||
<span className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
{!event.showWithoutTime && durationMinutes < 45 && (
|
||||
<span className="flex-shrink-0 opacity-85 text-[10.5px]">{safeFormat(startDate, timeFmt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{!event.showWithoutTime && durationMinutes >= 45 && (
|
||||
<div className="opacity-85 text-[10.5px]">
|
||||
{timeString}
|
||||
|
||||
Reference in New Issue
Block a user