fix: show event titles in their own direction and times start to end in right-to-left languages

This commit is contained in:
Linus Rath
2026-10-28 10:56:04 +01:00
parent 87323d4137
commit f353dbcfd0
8 changed files with 96 additions and 21 deletions
@@ -0,0 +1,65 @@
import { render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { EventCard } from '../event-card';
import { CalendarSearchResults } from '../calendar-search';
import { CalendarAgendaView } from '../calendar-agenda-view';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// In Arabic, an English title took the page's direction (".Dinner with
// Carlos and co", cut at the start) and "09:15 – 09:30" read "09:30 – 09:15".
vi.mock('@/lib/timezone', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/timezone')>()),
displayNow: () => new Date(2026, 9, 28, 8, 0),
}));
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
const dinner = {
id: 'e1', '@type': 'Event', uid: 'e1', title: 'Dinner with Carlos and co.', start: '2026-10-28T09:15:00',
duration: 'PT1H', showWithoutTime: false, status: 'confirmed', calendarIds: { 'cal-1': true },
} as unknown as CalendarEvent;
const isolatedLtr = (el: HTMLElement) => el.closest('bdi')?.getAttribute('dir') === 'ltr';
describe('user text and times in a right-to-left layout', () => {
it.each(['chip', 'span', 'block'] as const)('gives the event title its own direction (%s)', (variant) => {
render(<EventCard event={dinner} variant={variant} />);
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
});
it('keeps start before end in a block', () => {
render(<EventCard event={dinner} variant="block" />);
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
});
it('does the same in search results', () => {
render(
<CalendarSearchResults
query="dinner"
results={{ upcoming: [dinner], past: [], searching: false }}
calendars={calendars}
onSelectEvent={vi.fn()}
/>,
);
const row = screen.getByRole('button', { name: /Dinner/ });
expect(within(row).getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
expect(isolatedLtr(within(row).getByText('09:15 – 10:15'))).toBe(true);
});
it('does the same in the agenda', () => {
render(
<CalendarAgendaView
selectedDate={new Date(2026, 9, 28)}
focus={{ date: new Date(2026, 9, 28), nonce: 0 }}
windowKey="agenda:2026-10-28"
rangeStart={new Date(2026, 9, 28)}
rangeEnd={new Date(2026, 10, 3)}
events={[dinner]}
calendars={calendars}
onSelectEvent={vi.fn()}
/>,
);
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
});
});
+8 -4
View File
@@ -263,7 +263,7 @@ export function CalendarAgendaView({
style={{ paddingBlock: 'var(--density-item-py)' }}
>
<span className="w-28 sm:w-32 flex-shrink-0 text-sm text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
</span>
{/* Calendar colour as a dot on the title line; hollow when
@@ -275,13 +275,17 @@ export function CalendarAgendaView({
/>
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
{ev.title || t("events.no_title")}
{/* Sized to the title, which keeps its own direction: it sits at
the start of the row and is cut at its own end. */}
<div className="flex min-w-0">
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
{ev.title || t("events.no_title")}
</span>
</div>
{(meta || getParticipantCount(ev) > 0) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" />}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
{getParticipantCount(ev) > 0 && (
<span className="flex items-center gap-1 flex-shrink-0">
<Users className="w-3 h-3" />
+2 -2
View File
@@ -620,7 +620,7 @@ export function CalendarDayView({
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
</div>
</div>
)}
@@ -666,7 +666,7 @@ export function CalendarDayView({
{pendingPreview.title}
</div>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
</div>
</div>
);
+6 -4
View File
@@ -151,7 +151,7 @@ export function CalendarSearchResults({
: { month: "short", day: "numeric", year: "numeric" })}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
</span>
</span>
<span
@@ -160,13 +160,15 @@ export function CalendarSearchResults({
aria-hidden="true"
/>
<span className="flex-1 min-w-0">
<span className={cn("block text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
{title}
<span className="flex min-w-0">
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
{title}
</span>
</span>
{(meta || repeats) && (
<span className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" aria-hidden="true" />}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
{repeats && (
<span className="flex items-center gap-1 flex-shrink-0">
<Repeat className="w-3 h-3" aria-hidden="true" />
+2 -2
View File
@@ -804,7 +804,7 @@ export function CalendarWeekView({
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
</div>
</div>
)}
@@ -851,7 +851,7 @@ export function CalendarWeekView({
{pendingPreview.title}
</div>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
</div>
</div>
);
+5 -4
View File
@@ -197,7 +197,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)}
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }}
>
<span className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -227,7 +227,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 font-normal opacity-85">{format(startDate, timeFmt)}</span>
)}
<span className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -268,7 +268,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
block is wide enough to keep the title readable. */}
<div className="@container">
<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>
<span dir="auto" className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
{!event.showWithoutTime && durationMinutes < 45 && (
<span className="hidden @min-[120px]:inline flex-shrink-0 opacity-85 text-[10.5px]">{safeFormat(startDate, timeFmt)}</span>
)}
@@ -276,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
</div>
{!event.showWithoutTime && durationMinutes >= 45 && (
<div className="opacity-85 text-[10.5px]">
{timeString}
{/* Start before end in a right-to-left layout too. */}
<bdi dir="ltr">{timeString}</bdi>
</div>
)}
{getParticipantCount(event) > 0 && (
+7 -5
View File
@@ -423,7 +423,8 @@ export function EventDetailPopover({
"text-lg font-semibold leading-snug break-words text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
{/* Its own direction, next to the colour square. */}
<bdi>{event.title || t("events.no_title")}</bdi>
</h3>
{event.status === "tentative" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-amber-600 dark:text-amber-400">
@@ -486,7 +487,7 @@ export function EventDetailPopover({
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} – {formatTime(endDate)}
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
@@ -504,6 +505,7 @@ export function EventDetailPopover({
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline min-w-0 break-words"
dir="auto"
title={locationName}
>
{/^https?:\/\//i.test(locationName)
@@ -557,7 +559,7 @@ export function EventDetailPopover({
<div className="mt-1 space-y-0.5">
{participants.slice(0, 5).map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground" title={p.email}>
<span dir="auto" className="truncate text-foreground" title={p.email}>
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">
@@ -598,7 +600,7 @@ export function EventDetailPopover({
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
<span dir="auto" className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
</div>
)}
@@ -606,7 +608,7 @@ export function EventDetailPopover({
{event.description && (
<div className="flex items-start gap-2.5">
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
<p dir="auto" className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
<LinkifiedText text={event.description} />
</p>
</div>
+1
View File
@@ -68,6 +68,7 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, t
isCompleted && "line-through text-muted-foreground",
)}
title={title}
dir="auto"
>
{title}
</button>