From 3a888e11cc5e5eeda8466116fdd2c310545c73c7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:46:41 +0100 Subject: [PATCH 01/15] fix: open the import calendar dialog with the focus on the drop zone so Tab stays inside it --- .../calendar/__tests__/calendar-dialogs.test.tsx | 10 ++++++++++ components/calendar/ical-import-modal.tsx | 6 ++++++ components/ui/__tests__/dialog.test.tsx | 11 +++++++++++ components/ui/dialog.tsx | 4 +++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx index bc6b39924..4b408c769 100644 --- a/components/calendar/__tests__/calendar-dialogs.test.tsx +++ b/components/calendar/__tests__/calendar-dialogs.test.tsx @@ -61,6 +61,16 @@ describe('import dialog from the keyboard', () => { render(); expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument(); }); + + // It focused the hidden file input, which can't take the focus, so the + // focus stayed on the page behind and Tab walked the calendar. + it('opens with the focus on the drop zone, or on the address field for a link', () => { + const { unmount } = render(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: /select_file/ })); + unmount(); + render(); + expect(document.activeElement).toBe(screen.getByLabelText('tab_url')); + }); }); describe('import preview', () => { diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 29967fc5f..7d27dc3ae 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -53,6 +53,9 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal const [urlInput, setUrlInput] = useState(initialUrl || ""); const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); + // The dialog opens on the drop zone, or on the address field for a link. + const dropZoneRef = useRef(null); + const urlInputRef = useRef(null); const targetId = useId(); const validateFile = useCallback((file: File): string | null => { @@ -229,6 +232,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal onClose={close} title={t("title")} size="full-on-phone" + initialFocus={importMode === "url" ? urlInputRef : dropZoneRef} dirty={step !== "select" || urlInput.trim() !== (initialUrl ?? "")} footer={step !== "importing" ? ( <> @@ -281,6 +285,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal {importMode === "file" && ( <> + + + , + ); + expect(document.activeElement).toBe(screen.getByLabelText('name')); + }); + it('honours initialFocus', () => { function Host() { const target = useRef(null); diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 266f4f357..4512d1c74 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -53,8 +53,10 @@ const SIZES: Record = { "max-w-2xl max-sm:max-w-none max-sm:h-full max-sm:max-h-none max-sm:rounded-none max-sm:border-0", }; +// A field out of the tab order (the hidden input behind a file drop zone) +// can't take the focus: picking it left the focus on the page behind. const FIELD_SELECTOR = - 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"]'; + 'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([tabindex="-1"]):not(:disabled), textarea:not([tabindex="-1"]):not(:disabled), select:not([tabindex="-1"]):not(:disabled), [contenteditable="true"]'; /** * The one dialog shell (R4): a portal on at z-[60] over a dimmed From 6eaaca5cfa020cb21e318dff066753a4dce4d982 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:50:23 +0100 Subject: [PATCH 02/15] fix: move the focus into an event's card once it shows, so Enter on an event lands inside it --- .../event-detail-popover-focus.test.tsx | 70 +++++++++++++++++++ components/calendar/event-detail-popover.tsx | 25 ++++++- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 components/calendar/__tests__/event-detail-popover-focus.test.tsx diff --git a/components/calendar/__tests__/event-detail-popover-focus.test.tsx b/components/calendar/__tests__/event-detail-popover-focus.test.tsx new file mode 100644 index 000000000..787cc35c8 --- /dev/null +++ b/components/calendar/__tests__/event-detail-popover-focus.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// CAL-13: Enter on an event opened the card but the focus stayed on the +// event: the card focused its first button while it was still hidden, +// before it had been placed. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); + +const calendar = { + id: 'cal-1', + name: 'Work', + myRights: { mayWriteAll: true, mayWriteOwn: true }, +} as unknown as Calendar; + +const event = { + id: 'evt-1', + calendarIds: { 'cal-1': true }, + title: 'Review', + start: '2026-11-20T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + participants: null, + alerts: null, +} as unknown as CalendarEvent; + +describe('EventDetailPopover focus', () => { + it('takes the focus once it is visible, and gives it back to the event on close', () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + const visibilityAtFocus: string[] = []; + const onFocusIn = (e: FocusEvent) => { + const card = (e.target as HTMLElement).closest('[role="dialog"]'); + if (card) visibilityAtFocus.push(card.style.visibility); + }; + document.addEventListener('focusin', onFocusIn); + const { unmount } = render( + , + ); + const card = screen.getByRole('dialog', { name: 'Review' }); + expect(card.contains(document.activeElement)).toBe(true); + expect(visibilityAtFocus).toEqual(['visible']); + unmount(); + document.removeEventListener('focusin', onFocusIn); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); +}); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index b99da1688..63399b897 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -253,17 +253,40 @@ export function EventDetailPopover({ return () => observer.disconnect(); }, [anchorRect, isMobile]); + // A card opened by click or Enter takes the focus, and gives it back to + // the event when it closes. useEffect(() => { if (!pinned) return; const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const node = popoverRef.current; - node?.querySelector("button")?.focus({ preventScroll: true }); return () => { const active = document.activeElement; if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true }); }; }, [pinned]); + // The focus goes in once the card is placed: before, it is hidden and + // can't take the focus, which stayed on the event. A card that just + // turned visible can still count as hidden for a frame, so it tries again + // on the next ones, unless the focus moved somewhere else meanwhile. + const placed = position !== null; + useEffect(() => { + const node = popoverRef.current; + if (!pinned || !placed || !node) return; + const from = document.activeElement; + let frame = 0; + let tries = 0; + const attempt = () => { + const active = document.activeElement; + if (node.contains(active) || active !== from) return; + const first = node.querySelector("button"); + first?.focus({ preventScroll: true }); + if (first && document.activeElement !== first && ++tries < 30) frame = requestAnimationFrame(attempt); + }; + attempt(); + return () => cancelAnimationFrame(frame); + }, [pinned, placed]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); From 86b56d490af86b1ec2c72eb6ad77f55cbb291774 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:01:34 +0100 Subject: [PATCH 03/15] fix: give tasks in the phone day view a full row with a tappable circle and drop the empty all-day block --- .../calendar-day-view-phone.test.tsx | 66 +++++++++++++++++++ components/calendar/calendar-day-view.tsx | 29 ++++---- components/calendar/task-chip.tsx | 23 +++++-- 3 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 components/calendar/__tests__/calendar-day-view-phone.test.tsx diff --git a/components/calendar/__tests__/calendar-day-view-phone.test.tsx b/components/calendar/__tests__/calendar-day-view-phone.test.tsx new file mode 100644 index 000000000..c7365c4c4 --- /dev/null +++ b/components/calendar/__tests__/calendar-day-view-phone.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CalendarDayView } from '../calendar-day-view'; +import type { Calendar, CalendarEvent, CalendarTask } from '@/lib/jmap/types'; + +// CAL-27 follow-up: the phone day view drew tasks as 10px chips with a +// circle too small to tap, and a busy neighbouring day left an empty +// all-day block of ~150px above the hours of a day with none. + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +const holiday = { + id: 'ev-1', calendarIds: { 'cal-1': true }, title: 'Holiday', start: '2026-09-10T00:00:00', + duration: 'P1D', showWithoutTime: true, +} as unknown as CalendarEvent; + +const task = { + id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true }, + progress: 'needs-action', priority: 0, due: '2026-09-09', showWithoutTime: true, +} as unknown as CalendarTask; + +function renderDay(isMobile: boolean, tasks: CalendarTask[] = [task]) { + return render( + , + ); +} + +describe('day view on a phone', () => { + it('gives each task a full row with a 44px target for the circle', () => { + renderDay(true); + const chip = document.querySelector('[data-calendar-task="t1"]')!; + expect(chip).toHaveClass('min-h-11', 'text-xs'); + expect(within(chip).getByRole('button', { name: /mark_complete|Mark as complete/i })).toHaveClass('w-11', 'self-stretch'); + }); + + it('keeps the small shared chip on a wide screen', () => { + renderDay(false); + const chip = document.querySelector('[data-calendar-task="t1"]')!; + expect(chip).toHaveClass('h-full', 'text-[10px]'); + }); + + it('sizes the all-day row by the day in view, not by a busy neighbour', () => { + renderDay(true, []); + const today = document.querySelector('[data-day="2026-09-09"]')!; + const next = document.querySelector('[data-day="2026-09-10"]')!; + // The day in view has nothing all-day: no empty block under its date. + expect(within(today).queryByText(/all_day|All day/i)).toBeNull(); + expect(today).not.toHaveClass('h-0'); + // The neighbour's all-day event takes no height of its own. + expect(within(next).getByRole('button', { name: /Holiday/ })).toBeInTheDocument(); + expect(next).toHaveClass('h-0', 'min-h-full', 'overflow-hidden'); + expect(screen.getAllByRole('columnheader')).toHaveLength(3); + }); +}); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index d927ee602..b2518068b 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -197,14 +197,6 @@ export function CalendarDayView({ return map; }, [days, eventsByDay, hours]); - const hasAllDayArea = useMemo( - () => days.some((day) => { - const key = format(day, "yyyy-MM-dd"); - return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0; - }), - [days, eventsByDay, tasksByDay], - ); - const scrollToFocus = useCallback(() => { const root = rootRef.current; if (!root) return; @@ -232,6 +224,10 @@ export function CalendarDayView({ // not used: browsers re-snap on their own when columns are prepended, // which would double the scroll correction.) const visibleKeyRef = useRef(null); + // The day in view: its all-day events and tasks size the row under the + // dates, so a busy neighbour doesn't leave an empty block above the hours. + const [visibleKey, setVisibleKey] = useState(() => dayKey(focus.date)); + useEffect(() => { setVisibleKey(dayKey(focus.date)); }, [focus.date, focus.nonce]); const scrollFrameRef = useRef(null); const snapTimerRef = useRef | null>(null); const handleStripScroll = useCallback(() => { @@ -255,6 +251,7 @@ export function CalendarDayView({ const key = dayKey(day); if (key === visibleKeyRef.current) return; visibleKeyRef.current = key; + setVisibleKey(key); onVisibleDateChange?.(day); }); }, [colWidth, colCount, days, onVisibleDateChange]); @@ -352,7 +349,16 @@ export function CalendarDayView({ const allDayEvents = eventsByDay.get(key)?.allDay ?? []; const dayTasks = tasksByDay.get(key) ?? []; return ( -
+
- {hasAllDayArea && ( + {(allDayEvents.length > 0 || dayTasks.length > 0) && (
{ @@ -414,12 +420,13 @@ export function CalendarDayView({
{/* The same chip as the month and week grids. */} {dayTasks.map((task) => ( -
+
task.calendarIds[c.id])} onToggleComplete={onToggleTaskComplete} onSelect={onSelectTask} + touch={isMobile} />
))} diff --git a/components/calendar/task-chip.tsx b/components/calendar/task-chip.tsx index ccea5a78d..7df48a12d 100644 --- a/components/calendar/task-chip.tsx +++ b/components/calendar/task-chip.tsx @@ -10,6 +10,8 @@ interface CalendarTaskChipProps { calendar?: Calendar; onToggleComplete?: (task: CalendarTask) => void; onSelect?: (task: CalendarTask) => void; + /** A full row for a finger: 44px tall, a bigger circle with a 44px hit area (phone day view). */ + touch?: boolean; className?: string; } @@ -17,7 +19,7 @@ interface CalendarTaskChipProps { * A task on its due day in the month and week grids (#1107). The circle * toggles completion; the title opens the task in the editor. */ -export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, className }: CalendarTaskChipProps) { +export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, touch = false, className }: CalendarTaskChipProps) { const t = useTranslations("calendar"); const isCompleted = task.progress === "completed"; const color = calendar?.color || "#3b82f6"; @@ -26,30 +28,39 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, c return (
+ )} /> )} {section("calendar-search-upcoming", t("search.upcoming"), upcoming)} From 4dc13c229b2adcb6a23d49c39991c80b5467daf8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:28:13 +0100 Subject: [PATCH 08/15] fix: list a repeating event under its own title in calendar search when one occurrence was renamed --- hooks/__tests__/use-calendar-event-search.test.tsx | 11 +++++++++++ hooks/use-calendar-event-search.ts | 7 +++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/hooks/__tests__/use-calendar-event-search.test.tsx b/hooks/__tests__/use-calendar-event-search.test.tsx index 931d69726..a91bd1d3d 100644 --- a/hooks/__tests__/use-calendar-event-search.test.tsx +++ b/hooks/__tests__/use-calendar-event-search.test.tsx @@ -60,6 +60,17 @@ describe('useCalendarEventSearch (CAL-16)', () => { expect(result.current?.past.map((e) => e.id)).toEqual(['old', 'older']); }); + it('shows a series under its own title when its next occurrence was renamed', () => { + const { client } = clientWith(() => new Promise(() => {})); + const series = [ + makeEvent('sync-1', 'Sync edited', '2026-10-29T10:00:00', { uid: 'sync', recurrenceId: '2026-10-29T10:00:00' }), + makeEvent('sync-2', 'Sync', '2026-11-05T10:00:00', { uid: 'sync', recurrenceId: '2026-11-05T10:00:00' }), + makeEvent('sync-3', 'Sync', '2026-11-12T10:00:00', { uid: 'sync', recurrenceId: '2026-11-12T10:00:00' }), + ]; + const { result } = renderHook(() => useCalendarEventSearch('sync', client, series)); + expect(result.current?.upcoming.map((e) => e.id)).toEqual(['sync-1', 'sync-2']); + }); + it('keeps the loaded matches when the server can not search', async () => { const { client } = clientWith(async () => { throw new Error('unsupportedFilter'); }); const { result } = renderHook(() => useCalendarEventSearch('review', client, loaded)); diff --git a/hooks/use-calendar-event-search.ts b/hooks/use-calendar-event-search.ts index 9d8122c28..a1f48e830 100644 --- a/hooks/use-calendar-event-search.ts +++ b/hooks/use-calendar-event-search.ts @@ -22,8 +22,11 @@ export interface CalendarSearchResults { } // One row per event: a series found as loaded occurrences and as the -// server's base event shows once. -const seriesKey = (event: CalendarEvent) => `${event.accountId ?? ""} ${event.uid || event.originalId || event.id}`; +// server's base event shows once. An occurrence renamed on its own gets a +// row of its own, so the series still shows under its own title: the next +// occurrence being the renamed one hid the series. +const seriesKey = (event: CalendarEvent) => + `${event.accountId ?? ""} ${event.uid || event.originalId || event.id} ${event.title ?? ""}`; /** * Searches the user's events by title, place, description and participants From b96dc85ab9957408d21e9dbc2a515ea3ca2a05c7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:29:43 +0100 Subject: [PATCH 09/15] fix: leave read-only calendars out of the task editor's calendar list --- .../__tests__/task-modal-calendars.test.tsx | 36 +++++++++++++++++++ components/calendar/calendar-app.tsx | 2 ++ components/calendar/task-modal.tsx | 14 ++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 components/calendar/__tests__/task-modal-calendars.test.tsx diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx new file mode 100644 index 000000000..1bd0abf30 --- /dev/null +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { TaskModal } from '../task-modal'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; + +// The task editor's Calendar select offered the read-only Birthdays +// calendar, which the event editor leaves out. + +const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true }; +const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false }; +const calendars = [ + { id: 'work', name: 'Work', isShared: false, myRights: writable }, + { id: 'personal', name: 'Personal', isShared: false, myRights: writable }, + { id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly }, + { id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable }, +] as unknown as Calendar[]; +const isSubscriptionCalendar = (id: string) => id === 'feed'; + +describe('task editor calendar select', () => { + it('lists only the calendars a task can go into', () => { + render(); + const select = screen.getByLabelText('tasks.calendar'); + const names = within(select).getAllByRole('option').map((o) => o.textContent); + expect(names).toEqual(['Work', 'Personal']); + }); + + it('keeps the calendar of a task that is already in a read-only one', () => { + const task = { + id: 't1', '@type': 'Task', title: 'Card for Ann', calendarIds: { birthdays: true }, + progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null, + } as unknown as CalendarTask; + render(); + const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement; + expect(select.value).toBe('birthdays'); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b9d63985b..b56e29ceb 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2219,6 +2219,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} @@ -2380,6 +2381,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = key={editTask?.id ?? 'new-task'} task={editTask} calendars={displayCalendars} + isSubscriptionCalendar={isSubscriptionCalendar} onSave={handleSaveTask} onDelete={handleDeleteTask} onDirtyChange={setTaskEditorDirty} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 6d3069d4a..26507e54d 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -14,6 +14,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { taskToView } from "@/lib/plugin-projection"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { canCreateEventsIn } from "@/lib/calendar-editability"; interface TaskModalProps { task?: CalendarTask | null; @@ -25,6 +26,8 @@ interface TaskModalProps { /** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */ onDirtyChange?: (dirty: boolean) => void; isMobile?: boolean; + /** Subscribed feeds are read-only: no tasks go there. */ + isSubscriptionCalendar?: (calendarId: string) => boolean; } type PriorityLevel = "none" | "high" | "medium" | "low"; @@ -54,6 +57,7 @@ export function TaskModal({ onClose, onDirtyChange, isMobile: _isMobile, + isSubscriptionCalendar, }: TaskModalProps) { const t = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -63,7 +67,9 @@ export function TaskModal({ // task-detail-section: the saved task, for an existing task only. const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]); - const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn); + // The calendars a task can go into: not read-only ones (Birthdays, + // feeds), the same rule as the event editor. + const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)); const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? ""; const [title, setTitle] = useState(task?.title ?? ""); @@ -80,6 +86,8 @@ export function TaskModal({ } return defaultCalendarId; }); + // A task already in a read-only calendar keeps it listed. + const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c)); const [alertOption, setAlertOption] = useState(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -313,7 +321,7 @@ export function TaskModal({
{/* Calendar */} - {writableCalendars.length > 1 && ( + {calendarOptions.length > 1 && (
)); @@ -681,6 +695,7 @@ export function CalendarWeekView({ count={dayLayout.before} direction="before" onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)} + tabbable={inViewKeys.has(key)} /> )} {dayLayout && dayLayout.after > 0 && ( @@ -688,6 +703,7 @@ export function CalendarWeekView({ count={dayLayout.after} direction="after" onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)} + tabbable={inViewKeys.has(key)} /> )} @@ -728,6 +744,7 @@ export function CalendarWeekView({ draggable={editable} continuesBefore={clip.clippedStart} continuesAfter={clip.clippedEnd} + tabbable={inViewKeys.has(key)} /> {/* An edge beyond the visible hours has nothing to grab. */} {editable && !clip.clippedStart && ( diff --git a/components/calendar/display-hours-controls.tsx b/components/calendar/display-hours-controls.tsx index 01cd9ab6a..09ddbe575 100644 --- a/components/calendar/display-hours-controls.tsx +++ b/components/calendar/display-hours-controls.tsx @@ -9,13 +9,15 @@ interface HiddenEventsIndicatorProps { count: number; direction: "before" | "after"; onReveal: () => void; + /** False for a day scrolled out of view: Tab skips it. */ + tabbable?: boolean; } /** * Marks a day column that has events above or below the visible hours * (#1164); clicking it shows the whole day at the first of them. */ -export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEventsIndicatorProps) { +export function HiddenEventsIndicator({ count, direction, onReveal, tabbable = true }: HiddenEventsIndicatorProps) { const t = useTranslations("calendar.events"); const label = direction === "before" ? t("hidden_before", { count }) : t("hidden_after", { count }); const Icon = direction === "before" ? ChevronUp : ChevronDown; @@ -23,6 +25,7 @@ export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEven
)} @@ -851,7 +851,7 @@ export function CalendarWeekView({ {pendingPreview.title}
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} + {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
); diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 76f9f1143..9bc5c4073 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -197,7 +197,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM )} style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }} > - {event.title || t("events.no_title")} + {event.title || t("events.no_title")} ); } @@ -227,7 +227,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM {showTimeInMonthView && !event.showWithoutTime && ( {format(startDate, timeFmt)} )} - {event.title || t("events.no_title")} + {event.title || t("events.no_title")}
); @@ -268,7 +268,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM block is wide enough to keep the title readable. */}
- {event.title || t("events.no_title")} + {event.title || t("events.no_title")} {!event.showWithoutTime && durationMinutes < 45 && ( {safeFormat(startDate, timeFmt)} )} @@ -276,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{!event.showWithoutTime && durationMinutes >= 45 && (
- {timeString} + {/* Start before end in a right-to-left layout too. */} + {timeString}
)} {getParticipantCount(event) > 0 && ( diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 63399b897..5402d6dbb 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -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. */} + {event.title || t("events.no_title")} {event.status === "tentative" && ( @@ -486,7 +487,7 @@ export function EventDetailPopover({ {t("events.all_day")} ) : (
- {formatTime(startDate)} – {formatTime(endDate)} + {formatTime(startDate)} – {formatTime(endDate)} ({formatDurationDisplay(durationMinutes)})
)} @@ -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({
{participants.slice(0, 5).map((p) => (
- + {p.name || p.email} {p.isOrganizer && ( @@ -598,7 +600,7 @@ export function EventDetailPopover({ {calendar && (
- {calendar.name} + {calendar.name}
)} @@ -606,7 +608,7 @@ export function EventDetailPopover({ {event.description && (
-

+

diff --git a/components/calendar/task-chip.tsx b/components/calendar/task-chip.tsx index 687787a6f..e44ee92be 100644 --- a/components/calendar/task-chip.tsx +++ b/components/calendar/task-chip.tsx @@ -68,6 +68,7 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, t isCompleted && "line-through text-muted-foreground", )} title={title} + dir="auto" > {title} From 8e278b940a5d107c0719eb0768ab73515390ef17 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:58:35 +0100 Subject: [PATCH 14/15] fix: fit long view names in the phone view switcher and show the focus ring of a time slot under an event --- components/calendar/calendar-day-view.tsx | 2 +- components/calendar/calendar-toolbar.tsx | 4 +++- components/calendar/calendar-week-view.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index e5ee06ff6..db225598a 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -501,7 +501,7 @@ export function CalendarDayView({ onClick={() => handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" + className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary" style={{ height: hourHeight }} /> ))} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 5ef164429..e22a46ecc 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -267,6 +267,8 @@ export function CalendarToolbar({ )} {/* Row 2: View switcher pills + calendar toggle */} + {/* Each pill is as wide as its label plus an even share of the rest, so a + long label ("جدول الأعمال", "Aufgaben") neither wraps nor touches the edge. */}
{views.map((v) => ( @@ -274,7 +276,7 @@ export function CalendarToolbar({ key={v} onClick={() => onViewModeChange(v)} className={cn( - "flex-1 min-h-11 text-[11px] font-medium transition-colors touch-manipulation", + "flex-auto min-h-11 px-1.5 text-[11px] font-medium transition-colors touch-manipulation", v === viewMode ? "bg-primary text-primary-foreground" : "text-muted-foreground active:bg-muted" diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 2656d7ccd..20618acfb 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -685,7 +685,7 @@ export function CalendarWeekView({ onClick={() => handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" + className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary" style={{ height: hourHeight }} /> ))} From 19369be8b6690ad2355b055e9ef201e5f2f16c47 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:01:41 +0100 Subject: [PATCH 15/15] test: drop a prop the agenda view doesn't take from the right-to-left test --- components/calendar/__tests__/calendar-rtl-text.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/calendar/__tests__/calendar-rtl-text.test.tsx b/components/calendar/__tests__/calendar-rtl-text.test.tsx index b43bbc478..9b728c1f6 100644 --- a/components/calendar/__tests__/calendar-rtl-text.test.tsx +++ b/components/calendar/__tests__/calendar-rtl-text.test.tsx @@ -49,7 +49,6 @@ describe('user text and times in a right-to-left layout', () => { it('does the same in the agenda', () => { render(