diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx
index 843e003d9..4b36fb2f2 100644
--- a/components/calendar/__tests__/calendar-app.test.tsx
+++ b/components/calendar/__tests__/calendar-app.test.tsx
@@ -126,6 +126,18 @@ describe('CalendarApp keyboard shortcuts', () => {
fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' });
expect(useCalendarStore.getState().viewMode).toBe('month');
});
+
+ // New 6: the preview opened on top of the open editor.
+ it('opens no hover preview while an editor is open', async () => {
+ setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Retro', start: '2026-10-13T14:00:00' })]);
+ renderApp();
+ fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
+ fireEvent.click(await screen.findByRole('button', { name: 'events.edit' }));
+ await screen.findByRole('dialog', { name: 'events.edit' });
+ fireEvent.mouseEnter(screen.getAllByRole('button', { name: /Retro/ })[0]);
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByRole('dialog', { name: 'Retro' })).toBeNull();
+ });
});
@@ -340,6 +352,18 @@ describe('CalendarApp event search (CAL-16)', () => {
expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument();
});
+ it('offers Clear search when nothing matches, back to the calendar with the cursor in the box', async () => {
+ renderApp();
+ const box = screen.getByRole('searchbox', { name: 'search.title' });
+ fireEvent.change(box, { target: { value: 'zzz' } });
+ await screen.findByText('search.no_results', {}, { timeout: 3000 });
+ const results = screen.getByTestId('calendar-search-results');
+ fireEvent.click(within(results).getByRole('button', { name: 'search.clear' }));
+ expect(box).toHaveValue('');
+ expect(document.activeElement).toBe(box);
+ await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull());
+ });
+
it('is in the command palette', () => {
render(<>>);
const items = screen.getByTestId('palette').textContent ?? '';
@@ -378,7 +402,7 @@ describe('CalendarApp deleting', () => {
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' }));
- expect(await screen.findByRole('dialog', { name: 'delete_title' })).toBeInTheDocument();
+ expect(await screen.findByRole('dialog', { name: 'delete_question' })).toBeInTheDocument();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
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/__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/__tests__/calendar-rtl-text.test.tsx b/components/calendar/__tests__/calendar-rtl-text.test.tsx
new file mode 100644
index 000000000..9b728c1f6
--- /dev/null
+++ b/components/calendar/__tests__/calendar-rtl-text.test.tsx
@@ -0,0 +1,64 @@
+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()),
+ 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();
+ expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
+ });
+
+ it('keeps start before end in a block', () => {
+ render();
+ expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
+ });
+
+ it('does the same in search results', () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
+ expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
+ });
+});
diff --git a/components/calendar/__tests__/calendar-toolbar-phone.test.tsx b/components/calendar/__tests__/calendar-toolbar-phone.test.tsx
new file mode 100644
index 000000000..dc9171553
--- /dev/null
+++ b/components/calendar/__tests__/calendar-toolbar-phone.test.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { CalendarToolbar } from '../calendar-toolbar';
+
+// The phone day view's title was cut to "Th…": the long date beside the
+// menu, the back arrow, both chevrons, Today and the header buttons.
+
+vi.mock('@/components/layout/phone-header-actions', () => ({ PhoneHeaderActions: () => null }));
+
+function renderToolbar(overrides: Partial> = {}) {
+ render(
+ ,
+ );
+}
+
+describe('phone toolbar in the day view', () => {
+ it('names the day short, with the full date as its tooltip', () => {
+ renderToolbar();
+ const title = screen.getByText('Oct 28');
+ expect(title).toHaveAttribute('title', 'Wednesday, October 28, 2026');
+ });
+
+ it('puts the way back to the month in the menu button\'s place', () => {
+ renderToolbar({ onNavigateBack: vi.fn() });
+ expect(screen.getByRole('button', { name: 'back_to_month' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'nav_open_menu' })).toBeNull();
+ });
+
+ it('keeps the menu when there is no way back', () => {
+ renderToolbar();
+ expect(screen.getByRole('button', { name: 'nav_open_menu' })).toBeInTheDocument();
+ });
+});
diff --git a/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx
new file mode 100644
index 000000000..a188afdf6
--- /dev/null
+++ b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { CalendarWeekView } from '../calendar-week-view';
+import { CalendarDayView } from '../calendar-day-view';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+
+// Tab in the week view walked into the events of the weeks loaded on
+// either side and scrolled the grid off its week.
+
+const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
+
+function makeEvent(id: string, title: string, start: string, extra: Partial = {}): CalendarEvent {
+ return {
+ id, uid: id, '@type': 'Event', title, start, duration: 'PT1H', showWithoutTime: false,
+ calendarIds: { 'cal-1': true }, ...extra,
+ } as unknown as CalendarEvent;
+}
+
+const events = [
+ makeEvent('before', 'Last week', '2026-09-02T10:00:00'),
+ makeEvent('now', 'This week', '2026-09-09T10:00:00'),
+ makeEvent('after', 'Next week', '2026-09-16T10:00:00'),
+ makeEvent('trip', 'Trip', '2026-09-03T00:00:00', { showWithoutTime: true, duration: 'P1D' }),
+ makeEvent('fair', 'Fair', '2026-09-08T00:00:00', { showWithoutTime: true, duration: 'P1D' }),
+];
+
+const tabbable = (name: RegExp) => screen.getByRole('button', { name }).tabIndex >= 0;
+
+describe('tab order of the week view', () => {
+ it('leaves the events of the weeks on either side out of the tab order', () => {
+ render(
+ ,
+ );
+ expect(tabbable(/^This week/)).toBe(true);
+ expect(tabbable(/^Fair/)).toBe(true);
+ expect(tabbable(/^Last week/)).toBe(false);
+ expect(tabbable(/^Next week/)).toBe(false);
+ expect(tabbable(/^Trip/)).toBe(false);
+ });
+
+ it('keeps the grid tab stop on a day in view when the selected day is scrolled away', () => {
+ render(
+ ,
+ );
+ const stops = screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0);
+ expect(stops).toHaveLength(1);
+ expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-07T/);
+ });
+
+ it('does the same in the day view', () => {
+ render(
+ ,
+ );
+ expect(tabbable(/^Tuesday/)).toBe(true);
+ expect(tabbable(/^Monday/)).toBe(false);
+ });
+});
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/__tests__/recurrence-scope-dialog.test.tsx b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx
new file mode 100644
index 000000000..7f93c7c81
--- /dev/null
+++ b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import { RecurrenceScopeDialog } from '../recurrence-scope-dialog';
+
+// The delete scope dialog asked "Which events would you like to modify?":
+// no word of what is lost (R3), and a title without a question mark.
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, values?: Record) =>
+ values ? `${key}(${Object.values(values).join(',')})` : key,
+}));
+
+describe('RecurrenceScopeDialog', () => {
+ it('asks to delete with a question and says what goes for the chosen scope', () => {
+ render(
+ ,
+ );
+ const dialog = screen.getByRole('dialog', { name: 'delete_question' });
+ expect(dialog).toHaveAccessibleDescription('delete_this_message(Yoga)');
+ fireEvent.click(screen.getByLabelText('all_events'));
+ expect(dialog).toHaveAccessibleDescription('delete_all_message(Yoga)');
+ fireEvent.click(screen.getByLabelText('this_and_future'));
+ expect(dialog).toHaveAccessibleDescription('delete_following_message(Yoga)');
+ // The cancellation note for an organizer's event with participants
+ expect(screen.getByText('cancel_notification')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'delete' }).className).toMatch(/destructive/);
+ });
+
+ it('keeps the edit wording for an edit', () => {
+ render();
+ const dialog = screen.getByRole('dialog', { name: 'edit_title' });
+ expect(dialog).toHaveAccessibleDescription('description');
+ expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();
+ });
+});
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..15eb01375
--- /dev/null
+++ b/components/calendar/__tests__/task-modal-calendars.test.tsx
@@ -0,0 +1,49 @@
+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');
+ });
+});
+
+// The desktop task panel had no dialog role, unlike the event editor.
+describe('task editor panel', () => {
+ it('is a dialog named by its title on wide screens', () => {
+ render();
+ expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument();
+ });
+
+ it('leaves the role to the full-screen wrapper on a phone', () => {
+ render();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+});
diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx
index 850321a93..4dd69715a 100644
--- a/components/calendar/calendar-agenda-view.tsx
+++ b/components/calendar/calendar-agenda-view.tsx
@@ -263,7 +263,7 @@ export function CalendarAgendaView({
style={{ paddingBlock: 'var(--density-item-py)' }}
>
- {ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
+ {ev.showWithoutTime ? t("events.all_day") : {`${formatTime(start)} – ${formatTime(end)}`}}
{/* Calendar colour as a dot on the title line; hollow when
@@ -275,13 +275,17 @@ export function CalendarAgendaView({
/>
-
- {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. */}
+
+
+ {ev.title || t("events.no_title")}
+
{(meta || getParticipantCount(ev) > 0) && (
{locationName && }
- {meta && {meta}}
+ {meta && {meta}}
{getParticipantCount(ev) > 0 && (
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index ce2b3e79e..6b29b5ea0 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -91,6 +91,7 @@ import { useCalendarEventSearch } from "@/hooks/use-calendar-event-search";
import { CalendarSearchField, CalendarSearchResults } from "@/components/calendar/calendar-search";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
+import { useHoverPreviewGate } from "@/hooks/use-hover-preview-gate";
import {
computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, normalizeScrollWindowState,
scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions,
@@ -842,16 +843,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
[closeDetail, openEmptyContextMenu],
);
+ // No preview over an open editor, card or dialog, nor after one closed
+ // until the pointer moves.
+ const canHoverPreview = useHoverPreviewGate(showEventModal || showTaskModal || detailPinned || quickDraft !== null);
+
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (detailPinnedRef.current) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
- // Don't show hover popover if the sidebar is already open for this event
- if (showEventModal && editEvent?.id === event.id) return;
+ if (!canHoverPreview()) return;
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
hoverTimerRef.current = setTimeout(() => {
+ if (!canHoverPreview()) return;
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, ms);
@@ -859,7 +864,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
- }, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
+ }, [isMobile, calendarHoverPreview, canHoverPreview]);
const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
@@ -1790,6 +1795,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onContextMenuEvent={handleContextMenuEvent}
+ onClearSearch={() => { setSearchQuery(""); searchInputRef.current?.focus(); }}
/>
);
}
@@ -2213,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}
@@ -2375,6 +2382,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
key={editTask?.id ?? 'new-task'}
task={editTask}
calendars={displayCalendars}
+ isSubscriptionCalendar={isSubscriptionCalendar}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onDirtyChange={setTaskEditorDirty}
@@ -2430,6 +2438,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
&& userOrganizes(pendingScopeAction.event, currentUserEmails)
? t("participants.cancel_notification")
: undefined}
+ eventTitle={pendingScopeAction?.event.title || t("events.no_title")}
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx
index d927ee602..db225598a 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]);
@@ -298,6 +295,10 @@ export function CalendarDayView({
onMoveEvent,
});
+ // Tab stays in the day in view: the loaded days on either side are out of
+ // the tab order, or Tab walked into them and scrolled the strip.
+ const isInView = useCallback((key: string) => key === visibleKey, [visibleKey]);
+
const { slotProps } = useTimeGridFocus({
rootRef,
headerRef,
@@ -306,6 +307,7 @@ export function CalendarDayView({
hours,
selectedDate,
onCreate: onCreateAtTime,
+ isInView,
});
const formatHour = (h: number): string => {
@@ -352,7 +354,16 @@ export function CalendarDayView({
const allDayEvents = eventsByDay.get(key)?.allDay ?? [];
const dayTasks = tasksByDay.get(key) ?? [];
return (
-
);
diff --git a/components/calendar/calendar-search.tsx b/components/calendar/calendar-search.tsx
index 283cb8551..b12ef8aef 100644
--- a/components/calendar/calendar-search.tsx
+++ b/components/calendar/calendar-search.tsx
@@ -6,6 +6,7 @@ import { format } from "date-fns";
import { Search, X, MapPin, Repeat } from "@/components/icons";
import { Input } from "@/components/ui/input";
import { IconButton } from "@/components/ui/icon-button";
+import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
@@ -88,11 +89,13 @@ interface CalendarSearchResultsProps {
currentUserEmails?: string[];
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
+ /** Empties the search box, back to the calendar (R9). */
+ onClearSearch?: () => void;
}
/** The events a calendar search found, upcoming first, then past ones. */
export function CalendarSearchResults({
- query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent,
+ query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, onClearSearch,
}: CalendarSearchResultsProps) {
const t = useTranslations("calendar");
const intlFormatter = useDisplayDateFormatter();
@@ -148,7 +151,7 @@ export function CalendarSearchResults({
: { month: "short", day: "numeric", year: "numeric" })}
- {ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
+ {ev.showWithoutTime ? t("events.all_day") : {`${formatTime(start)} – ${formatTime(end)}`}}
-
- {title}
+
+
+ {title}
+
{(meta || repeats) && (
{locationName && }
- {meta && {meta}}
+ {meta && {meta}}
{repeats && (
@@ -199,6 +204,11 @@ export function CalendarSearchResults({
icon={Search}
title={t("search.no_results", { query: query.trim() })}
description={t("search.no_results_hint")}
+ actions={onClearSearch && (
+
+ )}
/>
)}
{section("calendar-search-upcoming", t("search.upcoming"), upcoming)}
diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx
index 2ba60813c..e22a46ecc 100644
--- a/components/calendar/calendar-toolbar.tsx
+++ b/components/calendar/calendar-toolbar.tsx
@@ -106,6 +106,7 @@ export function CalendarToolbar({
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
+ formatDayShort,
} = useCalendarLocale();
const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"]
@@ -152,8 +153,9 @@ export function CalendarToolbar({
: formatWeekRange(ws);
}
case "day":
+ // A phone's row has room for "Oct 28"; the full date is the tooltip.
return isMobile
- ? formatFullDate(titleDate)
+ ? formatDayShort(titleDate)
: formatFullDate(titleDate);
case "agenda":
return isMobile
@@ -227,7 +229,8 @@ export function CalendarToolbar({
) : (
/* Row 1: Back / Date nav / Today */
- {onMenuClick && (
+ {/* The way back to the month takes the menu's place: both left the date no room. */}
+ {onMenuClick && !onNavigateBack && (
@@ -242,7 +245,10 @@ export function CalendarToolbar({
)}
-
+
{getDateLabel()}
{hasDateNav && (
@@ -261,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) => (
@@ -268,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 506e394d4..20618acfb 100644
--- a/components/calendar/calendar-week-view.tsx
+++ b/components/calendar/calendar-week-view.tsx
@@ -394,6 +394,17 @@ export function CalendarWeekView({
onMoveEvent,
});
+ // The days in view. Tab stays in them: events of the weeks loaded on
+ // either side are out of the tab order, or Tab walked into them and
+ // scrolled the grid off its week.
+ const inViewKeys = useMemo(
+ () => new Set(days.slice(visibleStartIndex, visibleStartIndex + visibleColumns).map(dayKey)),
+ [days, visibleStartIndex, visibleColumns],
+ );
+ const isInView = useCallback((key: string) => inViewKeys.has(key), [inViewKeys]);
+ const spanInView = (startIndex: number, span: number) =>
+ startIndex < visibleStartIndex + visibleColumns && startIndex + span > visibleStartIndex;
+
const { slotProps, tabStopDayKey } = useTimeGridFocus({
rootRef,
headerRef,
@@ -402,6 +413,7 @@ export function CalendarWeekView({
hours,
selectedDate,
onCreate: onCreateAtTime,
+ isInView,
});
const formatHour = (h: number): string => {
@@ -553,6 +565,7 @@ export function CalendarWeekView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
+ tabbable={spanInView(segment.startIndex, segment.span)}
/>
- {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 b99da1688..5402d6dbb 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();
@@ -400,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" && (
@@ -463,7 +487,7 @@ export function EventDetailPopover({
{t("events.all_day")}
) : (