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:
Linus Rath
2026-10-28 03:49:48 +01:00
parent 65df81212e
commit bfbf67327c
9 changed files with 89 additions and 24 deletions
@@ -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');
});
+3 -3
View File
@@ -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" />
+3 -3
View File
@@ -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>
+16 -7
View File
@@ -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>
+2 -2
View File
@@ -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>
</>
)}
+5 -5
View File
@@ -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>