diff --git a/app/(main)/admin/__tests__/branding-placeholders.test.tsx b/app/(main)/admin/__tests__/branding-placeholders.test.tsx
new file mode 100644
index 000000000..9d75a6828
--- /dev/null
+++ b/app/(main)/admin/__tests__/branding-placeholders.test.tsx
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
+
+// Branding per domain: the image fields said "Enter URL (uploads only for
+// default scope)", cut off in the field and wrong for a single domain, which
+// can upload. Only a wildcard domain takes a URL alone.
+
+vi.mock('@/lib/browser-navigation', () => ({
+ apiFetch: vi.fn(async () => new Response(JSON.stringify({
+ domainBranding: { source: 'admin', value: [{ host: 'mail.example.com' }, { host: '*.example.com' }] },
+ }), { status: 200, headers: { 'content-type': 'application/json' } })),
+ withBasePath: (p: string) => p,
+}));
+const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
+vi.mock('@/stores/toast-store', () => ({ toast, useToastStore: () => [] }));
+vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children }));
+
+import { BrandingPage } from '../_pages/branding';
+
+afterEach(() => {
+ useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
+});
+
+describe('admin branding image fields', () => {
+ it('offers upload for a single domain and a URL only for a wildcard, with the full hint as a title', async () => {
+ render();
+
+ fireEvent.click(await screen.findByRole('button', { name: 'mail.example.com' }));
+ const favicon = screen.getByRole('textbox', { name: 'Favicon' });
+ expect(favicon.getAttribute('placeholder')).toBe('Enter URL or upload a file');
+ expect(favicon.getAttribute('title')).toBe('Enter URL or upload a file');
+
+ fireEvent.click(screen.getByRole('button', { name: '*.example.com' }));
+ const wildcardFavicon = screen.getByRole('textbox', { name: 'Favicon' });
+ expect(wildcardFavicon.getAttribute('placeholder')).toBe('Enter URL');
+ expect(wildcardFavicon.getAttribute('title')).toBe('Enter URL');
+ });
+
+ // R9: the result in the past tense, without "successfully".
+ it('reports an upload without "successfully"', async () => {
+ const { container } = render();
+ await screen.findByRole('button', { name: 'mail.example.com' });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [new File(['x'], 'logo.png', { type: 'image/png' })] } });
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalled());
+ expect(toast.success.mock.calls[0][0]).toBe('logo.png uploaded');
+ });
+});
diff --git a/app/(main)/admin/__tests__/save-bar-switches.test.tsx b/app/(main)/admin/__tests__/save-bar-switches.test.tsx
index 23a5d4ed2..b0d8d63ee 100644
--- a/app/(main)/admin/__tests__/save-bar-switches.test.tsx
+++ b/app/(main)/admin/__tests__/save-bar-switches.test.tsx
@@ -81,4 +81,30 @@ describe('admin telemetry page', () => {
expect.objectContaining({ url: '/api/admin/telemetry', body: { action: 'set-consent', consent: 'on' } }),
]));
});
+
+ it('shows no next send and no consent date while heartbeats are off', async () => {
+ routes['/api/admin/telemetry'] = {
+ ...(routes['/api/admin/telemetry'] as object),
+ consent: 'off', consentedAt: '2026-10-01T10:00:00Z', lastSentAt: '2026-10-02T10:00:00Z',
+ nextScheduledAt: new Date(Date.now() - 7 * 3600_000).toISOString(),
+ };
+ render();
+ await screen.findByRole('switch', { name: 'Send anonymous heartbeats' });
+ expect(screen.queryByText('Next scheduled')).toBeNull();
+ expect(screen.queryByText('Consented at')).toBeNull();
+ expect(screen.queryByText(/hours ago/)).toBeNull();
+ expect(screen.getByText('Last sent')).toBeTruthy();
+ });
+
+ it('shows a past next send as due, not as "hours ago", while heartbeats are on', async () => {
+ routes['/api/admin/telemetry'] = {
+ ...(routes['/api/admin/telemetry'] as object),
+ consent: 'on', consentedAt: '2026-10-01T10:00:00Z',
+ nextScheduledAt: new Date(Date.now() - 7 * 3600_000).toISOString(),
+ };
+ render();
+ await screen.findByText('Next scheduled');
+ expect(screen.getByText('Due now')).toBeTruthy();
+ expect(screen.getByText('Consented at')).toBeTruthy();
+ });
});
diff --git a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
index 7e5a92e95..160bd435a 100644
--- a/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
+++ b/app/(main)/admin/__tests__/unsaved-changes-guard.test.tsx
@@ -21,27 +21,27 @@ describe('admin unsaved changes guard (#1255)', () => {
expect(screen.queryByRole('alertdialog')).toBeNull();
});
- it('asks before leaving with unsaved edits; Stay keeps the admin on the page', () => {
+ it('asks before leaving with unsaved edits; Keep editing keeps the admin on the page', () => {
render(<>>);
const action = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(action));
- expect(screen.getByRole('alertdialog')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Stay' }));
+ expect(screen.getByRole('alertdialog', { name: 'Discard changes?' })).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
expect(action).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true });
});
- it('Leave runs the action and drops the unsaved flag', () => {
+ it('Discard runs the action and drops the unsaved flag', () => {
render(<>>);
const action = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(action));
- fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(action).toHaveBeenCalledTimes(1);
expect(useAdminUnsavedStore.getState().dirty).toEqual({});
});
- it('Escape closes the prompt like Stay', () => {
+ it('Escape closes the prompt like Keep editing', () => {
render(<>>);
const action = vi.fn();
act(() => useAdminUnsavedStore.getState().requestLeave(action));
@@ -58,12 +58,12 @@ describe('admin unsaved changes guard (#1255)', () => {
act(() => useAdminUnsavedStore.getState().setDirty('plugin-config', true));
act(() => useAdminUnsavedStore.getState().requestLeave(back, 'plugin-config'));
- fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(back).toHaveBeenCalledTimes(2);
expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true });
});
- it('holds a link click until the admin picks Leave', () => {
+ it('holds a link click until the admin picks Discard', () => {
const onLinkClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
render(
<>
@@ -76,7 +76,7 @@ describe('admin unsaved changes guard (#1255)', () => {
expect(onLinkClick).not.toHaveBeenCalled();
expect(screen.getByRole('alertdialog')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(onLinkClick).toHaveBeenCalledTimes(1);
});
@@ -103,7 +103,7 @@ describe('admin unsaved changes guard on Back', () => {
await act(async () => { await done; });
};
- it('keeps the page and asks when Back is pressed with unsaved edits; Stay keeps guarding', async () => {
+ it('keeps the page and asks when Back is pressed with unsaved edits; Keep editing keeps guarding', async () => {
window.history.pushState(null, '', '/admin/policy');
window.history.pushState(null, '', '/admin/telemetry');
render(<>>);
@@ -112,7 +112,7 @@ describe('admin unsaved changes guard on Back', () => {
expect(window.location.pathname).toBe('/admin/telemetry');
expect(screen.getByRole('alertdialog')).toBeTruthy();
- fireEvent.click(screen.getByRole('button', { name: 'Stay' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true });
// Back asks again: the page is still guarded.
@@ -121,14 +121,14 @@ describe('admin unsaved changes guard on Back', () => {
expect(screen.getByRole('alertdialog')).toBeTruthy();
});
- it('Leave goes back to the previous page', async () => {
+ it('Discard goes back to the previous page', async () => {
window.history.pushState(null, '', '/admin/policy');
window.history.pushState(null, '', '/admin/telemetry');
render(<>>);
await back();
const done = popped();
- fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
await act(async () => { await done; });
expect(window.location.pathname).toBe('/admin/policy');
expect(useAdminUnsavedStore.getState().dirty).toEqual({});
diff --git a/app/(main)/admin/_pages/branding.tsx b/app/(main)/admin/_pages/branding.tsx
index 70446999e..7d0495b8d 100644
--- a/app/(main)/admin/_pages/branding.tsx
+++ b/app/(main)/admin/_pages/branding.tsx
@@ -216,7 +216,7 @@ export function BrandingPage() {
if (res.ok) {
const data = await res.json();
- setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
+ setMessage({ type: 'success', text: `${file.name} uploaded` });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
@@ -396,7 +396,9 @@ export function BrandingPage() {
accept={field.accept}
value={currentValue(field.key)}
badge={overrideBadge(field.key)}
- placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
+ // Files upload to the default scope and to exact hosts; a wildcard has
+ // no single host to serve them from (the scope line says so).
+ placeholder={wildcardScope ? 'Enter URL' : 'Enter URL or upload a file'}
uploading={uploading === field.key}
uploadDisabled={wildcardScope}
uploaded={isUploadedFile(field.key)}
@@ -417,6 +419,7 @@ export function BrandingPage() {
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={placeholder}
+ title={placeholder}
aria-label={field.label}
autoComplete="off"
spellCheck={false}
@@ -592,6 +595,7 @@ function ImageItem({ label, accept, value, badge, placeholder, uploading, upload
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
+ title={placeholder}
aria-label={label}
autoComplete="off"
spellCheck={false}
diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx
index 73cdae984..7d03ca985 100644
--- a/app/(main)/admin/_pages/telemetry.tsx
+++ b/app/(main)/admin/_pages/telemetry.tsx
@@ -40,6 +40,13 @@ function timeAgo(iso: string | null): string {
return `${days} days ago`;
}
+/** When the next heartbeat goes out; a time already past means it is due. */
+function nextRun(iso: string | null): string {
+ if (!iso) return '-';
+ const at = new Date(iso);
+ return at.getTime() <= Date.now() ? 'Due now' : at.toLocaleString();
+}
+
export function TelemetryPage() {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
@@ -177,12 +184,18 @@ export function TelemetryPage() {
{timeAgo(status.lastSentAt)}
-
- {timeAgo(status.nextScheduledAt)}
-
-
- {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
-
+ {/* Only a running schedule has a next send and a consent date; with
+ heartbeats off they were stale ("Next scheduled: 7 hours ago"). */}
+ {isOn && (
+ <>
+
+ {nextRun(status.nextScheduledAt)}
+
+
+ {status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
+
+ >
+ )}
(null);
useEffect(() => {
@@ -124,10 +124,10 @@ export function UnsavedChangesGuard() {
isOpen={isOpen}
onClose={cancelLeave}
onConfirm={confirmLeave}
- title="Unsaved changes"
- message="You have changes that haven't been saved. If you leave now, they will be lost."
- cancelText="Stay"
- confirmText="Leave"
+ title="Discard changes?"
+ message="Your changes haven't been saved."
+ cancelText="Keep editing"
+ confirmText="Discard"
variant="destructive"
/>
);
diff --git a/components/admin/__tests__/admin-shell.test.tsx b/components/admin/__tests__/admin-shell.test.tsx
index 6c4027212..a6f41684c 100644
--- a/components/admin/__tests__/admin-shell.test.tsx
+++ b/components/admin/__tests__/admin-shell.test.tsx
@@ -39,13 +39,13 @@ describe('AdminShell', () => {
it('does not server-render the phone layout, which desktop would show until hydration', () => {
const html = renderToString(shell({ authenticated: false, isStalwartAutoLogin: false }));
- expect(html).not.toContain('Back to mail');
+ expect(html).not.toContain('back_to_mail');
expect(html).not.toContain('Search admin');
});
it('draws the desktop sidebar once on the client', () => {
render(shell({ authenticated: true, isStalwartAutoLogin: false }));
- expect(screen.getByText('Back to Mail')).toBeTruthy();
+ expect(screen.getByText('back_to_mail')).toBeTruthy();
});
it('hides the session links until the check says which kind of admin this is', () => {
@@ -66,7 +66,17 @@ describe('AdminShell', () => {
startPolling: () => {},
} as never);
render(shell({ authenticated: true, isStalwartAutoLogin: false }));
- const dot = screen.getAllByRole('img', { name: 'Update available' })[0];
- expect(dot.getAttribute('title')).toBe('Update available');
+ const dot = screen.getAllByRole('img', { name: 'update_available' })[0];
+ expect(dot.getAttribute('title')).toBe('update_available');
+ });
+
+ // Translated like the same dot on the navigation rail (shell namespace).
+ it('names an important update in the language of the user', () => {
+ useUpdateStore.setState({
+ status: { updateAvailable: true, severity: 'security', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' },
+ startPolling: () => {},
+ } as never);
+ render(shell({ authenticated: true, isStalwartAutoLogin: false }));
+ expect(screen.getAllByRole('img', { name: 'update_important' })[0].getAttribute('title')).toBe('update_important');
});
});
diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx
index e3a1e5b38..e0f6f0286 100644
--- a/components/admin/admin-shell.tsx
+++ b/components/admin/admin-shell.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import { usePathname } from 'next/navigation';
+import { useTranslations } from 'next-intl';
import {
ArrowLeft,
ArrowUpCircle,
@@ -80,6 +81,8 @@ const subscribeNothing = () => () => {};
*/
export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, onLogout, children }: AdminShellProps) {
const pathname = usePathname();
+ const tShell = useTranslations('shell');
+ const tSettings = useTranslations('settings');
const location = useAdminLocation();
const isDesktop = useIsDesktop();
// The server can't know the window width, so its HTML would be the phone
@@ -183,7 +186,8 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
if (id === 'version' && hasUpdate) {
// A dot only: role="img" makes screen readers announce its label, which
// they skip on a plain span; the title shows it on hover.
- const label = updateImportant ? 'Important update available' : 'Update available';
+ // The same words as the dot on the rail's Settings entry.
+ const label = tShell(updateImportant ? 'update_important' : 'update_available');
return (
@@ -384,7 +389,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
className="flex h-9 w-full items-center rounded-md px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
- Back to Mail
+ {tSettings('back_to_mail')}
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 db516779c..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}
@@ -2228,6 +2235,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onClick={openCreateForView}
className="absolute bottom-4 end-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={createLabel}
+ data-tour="create-event-button"
>
@@ -2374,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}
@@ -2429,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")}
) : (