mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
fix: put events on the right day in right-to-left languages and slide the phone sidebar in from the start edge
This commit is contained in:
@@ -127,7 +127,7 @@ describe('calendar display hours and working days (#1164)', () => {
|
||||
expect(eventBlock('weekend')).toBeNull();
|
||||
const trip = screen.getByText('Event trip').closest<HTMLElement>('.absolute')!;
|
||||
// Column 5 of 6, two columns wide (jsdom rounds the percentages)
|
||||
expect(trip.style.left).toBe('calc(66.6667% + 1px)');
|
||||
expect(trip.style.insetInlineStart).toBe('calc(66.6667% + 1px)');
|
||||
expect(trip.style.width).toBe('calc(33.3333% - 2px)');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// In Arabic the day grid mirrors, but the event chips laid over it were
|
||||
// placed with `left`, so Sunday's event was drawn under Monday. And the
|
||||
// phone's weekday headers were the first two letters of each name ("اث").
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => ({ 'days.mon': 'الاثنين' } as Record<string, string>)[key] ?? key,
|
||||
useLocale: () => 'ar',
|
||||
useFormatter: () => ({
|
||||
dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('ar', options).format(d),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CalendarMonthView } from '../calendar-month-view';
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
const birthday = {
|
||||
id: 'e1', uid: 'u1', title: 'Birthday', start: '2026-10-07T00:00:00', duration: 'P1D', showWithoutTime: true,
|
||||
calendarIds: { 'cal-1': true }, status: 'confirmed',
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
function renderMonth(isMobile = false) {
|
||||
return render(
|
||||
<CalendarMonthView
|
||||
focus={{ date: new Date(2026, 9, 7), nonce: 0 }}
|
||||
windowKey="month:2026-10-07"
|
||||
rangeStart={new Date(2026, 8, 28)}
|
||||
rangeEnd={new Date(2026, 10, 1)}
|
||||
selectedDate={new Date(2026, 9, 7)}
|
||||
events={[birthday]}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
isMobile={isMobile}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CalendarMonthView in a right-to-left language', () => {
|
||||
it('places event chips from the start edge, so they follow the mirrored grid', () => {
|
||||
renderMonth();
|
||||
const chip = screen.getByRole('button', { name: /Birthday/ }).parentElement as HTMLElement;
|
||||
// Wednesday is the third column of a week starting on Monday.
|
||||
expect(chip.style.insetInlineStart).toMatch(/^calc\(28\.57\d*% \+ 1px\)$/);
|
||||
expect(chip.style.left).toBe('');
|
||||
});
|
||||
|
||||
it('heads the phone grid with the language\'s short weekday names', () => {
|
||||
renderMonth(true);
|
||||
const monday = screen.getAllByRole('columnheader')[0];
|
||||
expect(monday.textContent).toBe(new Intl.DateTimeFormat('ar', { weekday: 'narrow' }).format(new Date(2026, 8, 28)));
|
||||
expect(monday.textContent).not.toBe('ال');
|
||||
});
|
||||
});
|
||||
@@ -97,10 +97,10 @@ describe('CalendarMonthView tasks', () => {
|
||||
// already has an event in row 0; Friday has no events.
|
||||
// (jsdom rounds the calc() percentage.)
|
||||
const thu = chipSlot('thu');
|
||||
expect(thu.style.left).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/);
|
||||
expect(thu.style.insetInlineStart).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/);
|
||||
expect(thu.style.top).toBe('22px');
|
||||
const fri = chipSlot('fri');
|
||||
expect(fri.style.left).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/);
|
||||
expect(fri.style.insetInlineStart).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/);
|
||||
expect(fri.style.top).toBe('0px');
|
||||
});
|
||||
|
||||
|
||||
@@ -1961,9 +1961,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
"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)]",
|
||||
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
!narrowSidebarOpen && "-translate-x-full rtl:translate-x-full"
|
||||
)
|
||||
)}
|
||||
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
|
||||
@@ -2137,7 +2137,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
{isMobile && (
|
||||
<Button
|
||||
onClick={openCreateForView}
|
||||
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
||||
className="absolute bottom-4 end-4 z-40 h-14 w-14 rounded-full shadow-lg"
|
||||
aria-label={createLabel}
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
|
||||
@@ -445,7 +445,7 @@ export function CalendarDayView({
|
||||
inside its row, where the header cannot cover it. */}
|
||||
{(h > firstHour || firstHour > 0) && (
|
||||
<span className={cn(
|
||||
"absolute right-2 leading-none",
|
||||
"absolute end-2 leading-none",
|
||||
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
|
||||
isMobile ? "text-[10px]" : "text-xs",
|
||||
)}>
|
||||
@@ -526,7 +526,7 @@ export function CalendarDayView({
|
||||
key={ev.id}
|
||||
className="absolute z-10 group/event"
|
||||
data-calendar-event
|
||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }}
|
||||
style={{ top, height, insetInlineStart: `${leftPct}%`, width: `${widthPct}%`, paddingInline: 2 }}
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
@@ -611,7 +611,7 @@ export function CalendarDayView({
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
|
||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||
<div className="absolute -top-4 start-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||
{formatSnapTime(dropTarget.minutes, timeFormat)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useScrollWindow } from "@/hooks/use-scroll-window";
|
||||
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
|
||||
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
|
||||
|
||||
interface CalendarMonthViewProps extends ScrollWindowViewProps {
|
||||
@@ -150,6 +151,14 @@ export function CalendarMonthView({
|
||||
|
||||
const tasksByDate = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
|
||||
|
||||
// Phones have room for one letter per weekday: the language's own short
|
||||
// form, not the first letters of the name ("اث" for Monday in Arabic).
|
||||
const intlFormatter = useDisplayDateFormatter();
|
||||
const narrowDayNames = useMemo(
|
||||
() => (weeks[0] ?? []).map((day) => intlFormatter.dateTime(day, { weekday: "narrow" })),
|
||||
[weeks, intlFormatter],
|
||||
);
|
||||
|
||||
const weekSegments = useMemo(() => {
|
||||
return weeks.map((week) => {
|
||||
const segments = buildWeekSegments(events, week);
|
||||
@@ -317,12 +326,12 @@ export function CalendarMonthView({
|
||||
className="sticky top-0 z-50 bg-background grid grid-cols-7 border-b border-border"
|
||||
role="row"
|
||||
>
|
||||
{dayHeaderKeys.map((d) => (
|
||||
<div key={d} role="columnheader" className={cn(
|
||||
{dayHeaderKeys.map((d, index) => (
|
||||
<div key={d} role="columnheader" aria-label={isMobile ? t(`days.${d}`) : undefined} className={cn(
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
|
||||
isMobile && "py-1.5 text-[11px]"
|
||||
)}>
|
||||
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
||||
{isMobile ? narrowDayNames[index] ?? t(`days.${d}`) : t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -470,7 +479,7 @@ export function CalendarMonthView({
|
||||
<div
|
||||
className="absolute px-0.5"
|
||||
style={{
|
||||
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||
insetInlineStart: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||
top: previewRow * rowHeight,
|
||||
height: chipHeight,
|
||||
@@ -500,7 +509,7 @@ export function CalendarMonthView({
|
||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
||||
insetInlineStart: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
||||
top: segment.row * rowHeight,
|
||||
height: chipHeight,
|
||||
@@ -536,7 +545,7 @@ export function CalendarMonthView({
|
||||
key={`task-${task.id}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / 7) * 100}% + 1px)`,
|
||||
insetInlineStart: `calc(${(dayIndex / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||
top: (dayEventRows[dayIndex] + taskIndex) * rowHeight,
|
||||
height: chipHeight,
|
||||
@@ -561,7 +570,7 @@ export function CalendarMonthView({
|
||||
key={`more-${dayIndex}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / 7) * 100}% + 1px)`,
|
||||
insetInlineStart: `calc(${(dayIndex / 7) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
||||
top: rowLimit * rowHeight,
|
||||
height: chipHeight,
|
||||
|
||||
@@ -466,7 +466,7 @@ export function CalendarSidebarPanel({
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0 rtl:rotate-180" />
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">{label}</span>
|
||||
</button>
|
||||
|
||||
@@ -410,7 +410,7 @@ export function CalendarToolbar({
|
||||
onClick={onPrev}
|
||||
aria-label={t("nav_prev")}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 rtl:rotate-180" />
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -419,7 +419,7 @@ export function CalendarToolbar({
|
||||
onClick={onNext}
|
||||
aria-label={t("nav_next")}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 rtl:rotate-180" />
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -512,7 +512,7 @@ export function CalendarWeekView({
|
||||
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(segment.startIndex / colCount) * 100}% + 1px)`,
|
||||
insetInlineStart: `calc(${(segment.startIndex / colCount) * 100}% + 1px)`,
|
||||
width: `calc(${(segment.span / colCount) * 100}% - 2px)`,
|
||||
top: segment.row * 24 + 2,
|
||||
height: 20,
|
||||
@@ -571,7 +571,7 @@ export function CalendarWeekView({
|
||||
key={`task-${task.id}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||
insetInlineStart: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / colCount) * 100}% - 2px)`,
|
||||
top: (baseRow + taskIndex) * 24 + 2,
|
||||
height: 20,
|
||||
@@ -607,7 +607,7 @@ export function CalendarWeekView({
|
||||
inside its row, where the header cannot cover it. */}
|
||||
{(h > firstHour || firstHour > 0) && (
|
||||
<span className={cn(
|
||||
"absolute right-2 leading-none",
|
||||
"absolute end-2 leading-none",
|
||||
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
|
||||
isMobile ? "text-[9px]" : "text-[10px]",
|
||||
)}>
|
||||
@@ -688,7 +688,7 @@ export function CalendarWeekView({
|
||||
key={ev.id}
|
||||
className="absolute z-10 group/event"
|
||||
data-calendar-event
|
||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 1, paddingRight: 1 }}
|
||||
style={{ top, height, insetInlineStart: `${leftPct}%`, width: `${widthPct}%`, paddingInline: 1 }}
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
@@ -773,7 +773,7 @@ export function CalendarWeekView({
|
||||
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
|
||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||
<div className="absolute -top-4 start-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||
{formatSnapTime(dropTarget.minutes, timeFormat)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user