mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: show event titles in their own direction and times start to end in right-to-left languages
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, t
|
||||
isCompleted && "line-through text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
dir="auto"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user