Merge branch 'main' into ux/mail3

This commit is contained in:
Linus Rath
2026-10-28 11:10:56 +01:00
138 changed files with 4046 additions and 1118 deletions
@@ -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(<BrandingPage />);
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(<BrandingPage />);
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');
});
});
@@ -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(<TelemetryPage />);
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(<TelemetryPage />);
await screen.findByText('Next scheduled');
expect(screen.getByText('Due now')).toBeTruthy();
expect(screen.getByText('Consented at')).toBeTruthy();
});
});
@@ -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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
@@ -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(<><UnsavedChangesGuard /><DirtySection dirty /></>);
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({});
+6 -2
View File
@@ -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}
+19 -6
View File
@@ -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<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true);
@@ -177,12 +184,18 @@ export function TelemetryPage() {
<SettingItem label="Last sent">
<span className="text-sm text-foreground">{timeAgo(status.lastSentAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(status.nextScheduledAt)}</span>
</SettingItem>
<SettingItem label="Consented at">
<span className="text-sm text-foreground">{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</span>
</SettingItem>
{/* Only a running schedule has a next send and a consent date; with
heartbeats off they were stale ("Next scheduled: 7 hours ago"). */}
{isOn && (
<>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{nextRun(status.nextScheduledAt)}</span>
</SettingItem>
<SettingItem label="Consented at">
<span className="text-sm text-foreground">{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</span>
</SettingItem>
</>
)}
</SettingsSection>
<SettingsSection
+6 -6
View File
@@ -49,8 +49,8 @@ export function UnsavedChangesGuard() {
// Back and the back gesture are a same-document popstate (the pages switch
// through pushState), which can't be cancelled. So while something is
// unsaved a copy of the page's entry sits on top: Back only takes that
// one off, the page stays, and the prompt asks. Stay puts the copy back;
// Leave goes back past the page for real.
// one off, the page stays, and the prompt asks. Keep editing puts the
// copy back; Discard goes back past the page for real.
// The mark on the page entry the copy was put on top of.
const guard = useRef<string | null>(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"
/>
);
@@ -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');
});
});
+8 -3
View File
@@ -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 (
<span
role="img"
@@ -282,7 +286,8 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
<a
href={`${prefix}/`}
className="flex h-10 w-10 items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted"
aria-label="Back to mail"
aria-label={tSettings('back_to_mail')}
title={tSettings('back_to_mail')}
>
<ArrowLeft className="h-5 w-5" />
</a>
@@ -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"
>
<ArrowLeft className="me-2 h-4 w-4" />
Back to Mail
{tSettings('back_to_mail')}
</a>
</div>
</div>
@@ -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(<><CalendarApp /><PaletteGroups /></>);
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();
});
@@ -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(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-08"
rangeStart={new Date(2026, 8, 8)}
rangeEnd={new Date(2026, 8, 10)}
selectedDate={new Date(2026, 8, 9)}
events={[holiday]}
calendars={calendars}
tasks={tasks}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
isMobile={isMobile}
/>,
);
}
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-day="2026-09-09"]')!;
const next = document.querySelector<HTMLElement>('[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);
});
});
@@ -61,6 +61,16 @@ describe('import dialog from the keyboard', () => {
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
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(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByRole('button', { name: /select_file/ }));
unmount();
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} initialUrl="https://example.com/a.ics" />);
expect(document.activeElement).toBe(screen.getByLabelText('tab_url'));
});
});
describe('import preview', () => {
@@ -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<typeof import('@/lib/timezone')>()),
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(<EventCard event={dinner} variant={variant} />);
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
});
it('keeps start before end in a block', () => {
render(<EventCard event={dinner} variant="block" />);
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
});
it('does the same in search results', () => {
render(
<CalendarSearchResults
query="dinner"
results={{ upcoming: [dinner], past: [], searching: false }}
calendars={calendars}
onSelectEvent={vi.fn()}
/>,
);
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(
<CalendarAgendaView
focus={{ date: new Date(2026, 9, 28), nonce: 0 }}
windowKey="agenda:2026-10-28"
rangeStart={new Date(2026, 9, 28)}
rangeEnd={new Date(2026, 10, 3)}
events={[dinner]}
calendars={calendars}
onSelectEvent={vi.fn()}
/>,
);
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
});
});
@@ -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<React.ComponentProps<typeof CalendarToolbar>> = {}) {
render(
<CalendarToolbar
selectedDate={new Date(2026, 9, 28)}
viewMode="day"
onPrev={vi.fn()}
onNext={vi.fn()}
onToday={vi.fn()}
onViewModeChange={vi.fn()}
onCreateEvent={vi.fn()}
isMobile
onMenuClick={vi.fn()}
{...overrides}
/>,
);
}
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();
});
});
@@ -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> = {}): 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(
<CalendarWeekView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="week:2026-08-31"
// Three weeks loaded: 31 Aug to 20 Sep, the middle one in view.
rangeStart={new Date(2026, 7, 31)}
rangeEnd={new Date(2026, 8, 20)}
selectedDate={new Date(2026, 8, 9)}
events={events}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
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(
<CalendarWeekView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="week:2026-08-31"
rangeStart={new Date(2026, 7, 31)}
rangeEnd={new Date(2026, 8, 20)}
selectedDate={new Date(2026, 8, 2)}
events={[]}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
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(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-08"
rangeStart={new Date(2026, 8, 8)}
rangeEnd={new Date(2026, 8, 10)}
selectedDate={new Date(2026, 8, 9)}
events={[makeEvent('mon', 'Monday', '2026-09-08T10:00:00'), makeEvent('tue', 'Tuesday', '2026-09-09T10:00:00')]}
calendars={calendars}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
expect(tabbable(/^Tuesday/)).toBe(true);
expect(tabbable(/^Monday/)).toBe(false);
});
});
@@ -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<HTMLElement>('[role="dialog"]');
if (card) visibilityAtFocus.push(card.style.visibility);
};
document.addEventListener('focusin', onFocusIn);
const { unmount } = render(
<EventDetailPopover
event={event}
calendar={calendar}
anchorRect={new DOMRect(100, 100, 50, 20)}
pinned
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
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();
});
});
@@ -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<string, unknown>) =>
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(
<RecurrenceScopeDialog isOpen actionType="delete" eventTitle="Yoga" note="cancel_notification" onSelect={vi.fn()} onClose={vi.fn()} />,
);
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(<RecurrenceScopeDialog isOpen actionType="edit" eventTitle="Yoga" onSelect={vi.fn()} onClose={vi.fn()} />);
const dialog = screen.getByRole('dialog', { name: 'edit_title' });
expect(dialog).toHaveAccessibleDescription('description');
expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();
});
});
@@ -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(<TaskModal calendars={calendars} isSubscriptionCalendar={isSubscriptionCalendar} onSave={vi.fn()} onClose={vi.fn()} />);
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(<TaskModal task={task} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument();
});
it('leaves the role to the full-screen wrapper on a phone', () => {
render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} isMobile />);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
+8 -4
View File
@@ -263,7 +263,7 @@ export function CalendarAgendaView({
style={{ paddingBlock: 'var(--density-item-py)' }}
>
<span className="w-28 sm:w-32 flex-shrink-0 text-sm text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
</span>
{/* Calendar colour as a dot on the title line; hollow when
@@ -275,13 +275,17 @@ export function CalendarAgendaView({
/>
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
{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. */}
<div className="flex min-w-0">
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
{ev.title || t("events.no_title")}
</span>
</div>
{(meta || getParticipantCount(ev) > 0) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" />}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
{getParticipantCount(ev) > 0 && (
<span className="flex items-center gap-1 flex-shrink-0">
<Users className="w-3 h-3" />
+13 -3
View File
@@ -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"
>
<Plus className="h-6 w-6" />
</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)}
/>
+31 -14
View File
@@ -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<string | null>(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<number | null>(null);
const snapTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div key={key} data-day={key} className="min-w-0 border-e border-border last:border-e-0">
<div
key={key}
data-day={key}
className={cn(
"min-w-0 border-e border-border last:border-e-0",
// Other days take no height of their own: they fill the
// row the day in view sets, clipped while swiping past.
key !== visibleKey && "h-0 min-h-full overflow-hidden",
)}
>
<div
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
role="columnheader"
@@ -376,7 +387,7 @@ export function CalendarDayView({
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
</div>
{hasAllDayArea && (
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
<div
className="px-4 py-2 border-t border-border"
onContextMenu={onContextMenuEmpty ? (e) => {
@@ -402,6 +413,7 @@ export function CalendarDayView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
tabbable={key === visibleKey}
/>
);
})}
@@ -414,12 +426,14 @@ export function CalendarDayView({
<div className="space-y-0.5">
{/* The same chip as the month and week grids. */}
{dayTasks.map((task) => (
<div key={task.id} className="h-5">
<div key={task.id} className={isMobile ? undefined : "h-5"}>
<CalendarTaskChip
task={task}
calendar={calendars.find((c) => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
touch={isMobile}
tabbable={key === visibleKey}
/>
</div>
))}
@@ -487,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 }}
/>
))}
@@ -497,6 +511,7 @@ export function CalendarDayView({
count={dayLayout.before}
direction="before"
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
tabbable={key === visibleKey}
/>
)}
{dayLayout && dayLayout.after > 0 && (
@@ -504,6 +519,7 @@ export function CalendarDayView({
count={dayLayout.after}
direction="after"
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
tabbable={key === visibleKey}
/>
)}
@@ -541,6 +557,7 @@ export function CalendarDayView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
tabbable={key === visibleKey}
draggable={editable}
continuesBefore={clip.clippedStart}
continuesAfter={clip.clippedEnd}
@@ -603,7 +620,7 @@ export function CalendarDayView({
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
</div>
</div>
)}
@@ -649,7 +666,7 @@ export function CalendarDayView({
{pendingPreview.title}
</div>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
</div>
</div>
);
+15 -5
View File
@@ -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" })}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
</span>
</span>
<span
@@ -157,13 +160,15 @@ export function CalendarSearchResults({
aria-hidden="true"
/>
<span className="flex-1 min-w-0">
<span className={cn("block text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
{title}
<span className="flex min-w-0">
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
{title}
</span>
</span>
{(meta || repeats) && (
<span className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" aria-hidden="true" />}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
{repeats && (
<span className="flex items-center gap-1 flex-shrink-0">
<Repeat className="w-3 h-3" aria-hidden="true" />
@@ -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 && (
<Button size="sm" variant="outline" onClick={onClearSearch}>
{t("search.clear")}
</Button>
)}
/>
)}
{section("calendar-search-upcoming", t("search.upcoming"), upcoming)}
+12 -4
View File
@@ -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 */
<div className="flex items-center gap-1">
{onMenuClick && (
{/* The way back to the month takes the menu's place: both left the date no room. */}
{onMenuClick && !onNavigateBack && (
<IconButton size="icon-lg" label={t("nav_open_menu")} onClick={onMenuClick} className="-ms-1 touch-manipulation">
<Menu />
</IconButton>
@@ -242,7 +245,10 @@ export function CalendarToolbar({
<ChevronLeft />
</IconButton>
)}
<span className="text-sm font-semibold text-center flex-1 select-none truncate" title={getDateLabel()}>
<span
className="text-sm font-semibold text-center flex-1 select-none truncate"
title={viewMode === "day" ? formatFullDate(titleDate) : getDateLabel()}
>
{getDateLabel()}
</span>
{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. */}
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{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"
+20 -3
View File
@@ -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)}
/>
</div>
);
@@ -606,6 +619,7 @@ export function CalendarWeekView({
calendar={calendars.find(c => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
tabbable={inViewKeys.has(key)}
/>
</div>
));
@@ -671,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 }}
/>
))}
@@ -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 && (
@@ -787,7 +804,7 @@ export function CalendarWeekView({
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
</div>
</div>
)}
@@ -834,7 +851,7 @@ export function CalendarWeekView({
{pendingPreview.title}
</div>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
</div>
</div>
);
@@ -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
<button
type="button"
data-hidden-events={direction}
tabIndex={tabbable ? undefined : -1}
title={label}
aria-label={label}
// The column starts drag-to-create on pointer down.
+11 -5
View File
@@ -33,6 +33,8 @@ interface EventCardProps {
style?: CSSProperties;
/** The user's calendar addresses, to tell whether they declined the event. */
currentUserEmails?: string[];
/** False for a day scrolled out of view: Tab skips it (arrows still reach it). */
tabbable?: boolean;
}
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
@@ -98,7 +100,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -184,6 +186,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
{...dragProps}
className={cn(
"flex items-center w-full text-start text-xs font-medium px-1.5 py-0.5 rounded-xs truncate",
@@ -194,7 +197,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)}
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }}
>
<span className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -208,6 +211,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
{...dragProps}
className={cn(
"w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
@@ -223,7 +227,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 font-normal opacity-85">{format(startDate, timeFmt)}</span>
)}
<span className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -237,6 +241,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
{...dragProps}
data-calendar-event
data-continues-before={continuesBefore || undefined}
@@ -263,7 +268,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
block is wide enough to keep the title readable. */}
<div className="@container">
<div className="flex items-baseline gap-1 min-w-0">
<span className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
{!event.showWithoutTime && durationMinutes < 45 && (
<span className="hidden @min-[120px]:inline flex-shrink-0 opacity-85 text-[10.5px]">{safeFormat(startDate, timeFmt)}</span>
)}
@@ -271,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
</div>
{!event.showWithoutTime && durationMinutes >= 45 && (
<div className="opacity-85 text-[10.5px]">
{timeString}
{/* Start before end in a right-to-left layout too. */}
<bdi dir="ltr">{timeString}</bdi>
</div>
)}
{getParticipantCount(event) > 0 && (
+31 -6
View File
@@ -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<HTMLElement>("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<HTMLElement>("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. */}
<bdi>{event.title || t("events.no_title")}</bdi>
</h3>
{event.status === "tentative" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-amber-600 dark:text-amber-400">
@@ -463,7 +487,7 @@ export function EventDetailPopover({
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} – {formatTime(endDate)}
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
@@ -481,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)
@@ -534,7 +559,7 @@ export function EventDetailPopover({
<div className="mt-1 space-y-0.5">
{participants.slice(0, 5).map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground" title={p.email}>
<span dir="auto" className="truncate text-foreground" title={p.email}>
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">
@@ -575,7 +600,7 @@ export function EventDetailPopover({
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
<span dir="auto" className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
</div>
)}
@@ -583,7 +608,7 @@ export function EventDetailPopover({
{event.description && (
<div className="flex items-start gap-2.5">
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
<p dir="auto" className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
<LinkifiedText text={event.description} />
</p>
</div>
+15 -5
View File
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
import { Upload, Check, RefreshCw, Globe } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
@@ -53,6 +52,9 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// The dialog opens on the drop zone, or on the address field for a link.
const dropZoneRef = useRef<HTMLButtonElement>(null);
const urlInputRef = useRef<HTMLInputElement>(null);
const targetId = useId();
const validateFile = useCallback((file: File): string | null => {
@@ -209,10 +211,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
if (!event.start) return "";
try {
const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime
? format(date, "MMM d, yyyy")
: format(date, `MMM d, yyyy ${timeFmt}`);
// The locale's own order and month names, not an English pattern.
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "short",
day: "numeric",
year: "numeric",
...(event.showWithoutTime ? {} : timeFormat === "12h"
? { hour: "numeric", minute: "2-digit", hour12: true }
: { hour: "2-digit", minute: "2-digit", hour12: false }),
});
} catch {
return event.start;
}
@@ -229,6 +236,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 +289,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{importMode === "file" && (
<>
<button
ref={dropZoneRef}
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
@@ -313,6 +322,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
<p className="text-sm text-muted-foreground">{t("url_description")}</p>
<div className="flex gap-2">
<input
ref={urlInputRef}
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
@@ -15,14 +15,23 @@ interface RecurrenceScopeDialogProps {
onClose: () => void;
/** A line under the question, e.g. that the participants get a cancellation. */
note?: string;
/** The series' title: a delete says which of its events go (R3). */
eventTitle?: string;
}
const DELETE_MESSAGES: Record<RecurrenceEditScope, string> = {
this: "delete_this_message",
this_and_future: "delete_following_message",
all: "delete_all_message",
};
export function RecurrenceScopeDialog({
isOpen,
actionType,
onSelect,
onClose,
note,
eventTitle = "",
}: RecurrenceScopeDialogProps) {
const t = useTranslations("calendar.recurrence_scope");
const id = useId();
@@ -72,10 +81,13 @@ export function RecurrenceScopeDialog({
</div>
<div>
<h2 id={`${id}-title`} className="text-lg font-semibold">
{isDelete ? t("delete_title") : isRsvp ? t("rsvp_title") : t("edit_title")}
{isDelete ? t("delete_question") : isRsvp ? t("rsvp_title") : t("edit_title")}
</h2>
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1">
{isRsvp ? t("rsvp_description") : t("description")}
{/* A delete says what goes with the chosen scope, and that it is for good. */}
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1" aria-live="polite">
{isDelete
? t(DELETE_MESSAGES[choice], { title: eventTitle })
: isRsvp ? t("rsvp_description") : t("description")}
</p>
{note && <p className="text-sm text-muted-foreground mt-1">{note}</p>}
</div>
+22 -6
View File
@@ -10,6 +10,10 @@ 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;
/** False for a day scrolled out of view: Tab skips it. */
tabbable?: boolean;
className?: string;
}
@@ -17,7 +21,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, tabbable = true, className }: CalendarTaskChipProps) {
const t = useTranslations("calendar");
const isCompleted = task.progress === "completed";
const color = calendar?.color || "#3b82f6";
@@ -26,33 +30,45 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, c
return (
<div
data-calendar-task={task.id}
className={cn("h-full rounded text-[10px] font-medium flex items-center overflow-hidden", className)}
className={cn(
"rounded font-medium flex items-center overflow-hidden",
touch ? "min-h-11 text-xs" : "h-full text-[10px]",
className,
)}
style={{ backgroundColor: `${color}20`, borderInlineStart: `3px solid ${color}` }}
>
<button
type="button"
tabIndex={tabbable ? undefined : -1}
onClick={(e) => { e.stopPropagation(); onToggleComplete?.(task); }}
className="group h-full ps-1 pe-0.5 flex items-center flex-shrink-0"
className={cn(
"group flex items-center flex-shrink-0",
touch ? "self-stretch w-11 justify-center" : "h-full ps-1 pe-0.5",
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
<span
className={cn(
"w-2.5 h-2.5 rounded-full border flex items-center justify-center",
"rounded-full border flex items-center justify-center",
touch ? "w-4 h-4" : "w-2.5 h-2.5",
isCompleted ? "bg-success border-success text-success-foreground" : "group-hover:bg-background",
)}
style={isCompleted ? undefined : { borderColor: color }}
>
{isCompleted && <Check className="h-2 w-2" />}
{isCompleted && <Check className={touch ? "h-3 w-3" : "h-2 w-2"} />}
</span>
</button>
<button
type="button"
tabIndex={tabbable ? undefined : -1}
onClick={(e) => { e.stopPropagation(); onSelect?.(task); }}
className={cn(
"h-full flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
"flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
touch ? "self-stretch" : "h-full",
isCompleted && "line-through text-muted-foreground",
)}
title={title}
dir="auto"
>
{title}
</button>
+21 -6
View File
@@ -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";
@@ -53,7 +56,8 @@ export function TaskModal({
onDelete,
onClose,
onDirtyChange,
isMobile: _isMobile,
isMobile = false,
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<AlertOption>(() => {
if (!task?.alerts) return "none";
const first = Object.values(task.alerts)[0];
@@ -205,10 +213,17 @@ export function TaskModal({
}, [requestClose, handleSave]);
return (
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
// The desktop side panel is a dialog named by its title, like the event
// editor; on a phone the full-screen wrapper around it is.
<div
role={isMobile ? undefined : "dialog"}
aria-labelledby={isMobile ? undefined : `${id}-heading`}
className="flex flex-col h-full bg-background"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-sm font-semibold">
<h2 id={`${id}-heading`} className="text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose}>
@@ -313,7 +328,7 @@ export function TaskModal({
</div>
{/* Calendar */}
{writableCalendars.length > 1 && (
{calendarOptions.length > 1 && (
<div className="space-y-2">
<label htmlFor={`${id}-calendar`} className="text-xs font-medium text-muted-foreground">
{t("tasks.calendar")}
@@ -324,7 +339,7 @@ export function TaskModal({
onChange={(e) => setCalendarId(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
{writableCalendars.map((cal) => (
{calendarOptions.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</select>
@@ -97,6 +97,25 @@ describe('command palette', () => {
expect(screen.queryByText('group_plugins')).toBeNull();
});
it("shows Mail's c and / hints only in Mail, where those keys compose and search", () => {
const hint = (label: string) => {
const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!;
return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' ');
};
const { unmount } = render(<CommandPaletteHost />);
pressCtrlK();
expect(hint('compose')).toBe('c');
expect(hint('search')).toBe('/');
unmount();
// Calendar has its own c (new event) and / (search events).
nav.pathname = '/calendar/week/2026-10-28';
render(<CommandPaletteHost />);
pressCtrlK();
expect(hint('compose')).toBe('');
expect(hint('search')).toBe('');
});
it('shows plugin entries with the plugin name and runs them with Enter', async () => {
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
render(<CommandPaletteHost />);
@@ -206,10 +206,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
run: go(pluginPageHref(page)),
});
}
// "c" and "/" compose and search only in Mail; another app may give the
// same keys its own meaning (Calendar: new event, search events), listed
// with its own commands above.
const inMail = pathname === "/" || pathname === "" || pathname === "/mail" || pathname.startsWith("/mail/");
const appKeys = new Set(list.map((c) => c.shortcut).filter(Boolean));
const mailKey = (key: string) => (inMail && !appKeys.has(key) ? key : undefined);
list.push(
{ id: "go-settings", group: navigation, icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
{ id: "compose", group: actions, icon: PenSquare, label: t("compose"), shortcut: "c", run: onMail("compose") },
{ id: "search", group: actions, icon: Search, label: t("search"), shortcut: "/", run: onMail("search") },
{ id: "compose", group: actions, icon: PenSquare, label: t("compose"), shortcut: mailKey("c"), run: onMail("compose") },
{ id: "search", group: actions, icon: Search, label: t("search"), shortcut: mailKey("/"), run: onMail("search") },
{
id: "search-everything",
group: actions,
@@ -257,7 +263,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
});
}
return list;
}, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, pluginPages, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]);
}, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, pluginPages, registeredGroups, accounts, activeAccountId, router, pathname, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]);
const needle = query.trim();
const visible = needle
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { AddressBookPickerDialog } from '../address-book-picker-dialog';
import type { AddressBook } from '@/lib/jmap/types';
// next-intl is mocked globally (t returns the key).
const books = [
{ id: 'b1', name: 'Personal' },
{ id: 'b2', name: 'Work' },
] as unknown as AddressBook[];
describe('AddressBookPickerDialog', () => {
it('says the contacts are being moved, not added', () => {
render(<AddressBookPickerDialog isOpen onClose={vi.fn()} addressBooks={books} currentId="b1" count={1} onPick={vi.fn()} />);
expect(screen.getByText('bulk.moving_contacts')).toBeInTheDocument();
expect(screen.queryByText('bulk.adding_contacts')).toBeNull();
});
});
@@ -1,6 +1,7 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ContactForm } from '../contact-form';
import { ContactGroupForm } from '../contact-group-form';
import type { ContactCard, ContactMedia } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { findContactPhotoEntry } from '@/stores/contact-store';
@@ -86,6 +87,43 @@ describe('ContactForm', () => {
expect(savedData.media).toBeNull();
});
it('adds a category typed without Enter when the contact is saved, in the spelling of an existing one', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm contact={existingContact} allKeywords={['Friends']} onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('categories'));
fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'friends, Gym ' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
expect(onSave.mock.calls[0][0].keywords).toEqual({ Friends: true, Gym: true });
});
it('counts a typed category as a change, so leaving asks first', () => {
const onDirtyChange = vi.fn();
render(<ContactForm contact={existingContact} onSave={vi.fn()} onCancel={vi.fn()} onDirtyChange={onDirtyChange} />);
fireEvent.click(screen.getByText('categories'));
fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'Gym' } });
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
});
it.each([
['contact form', () => <ContactForm onSave={vi.fn()} onCancel={vi.fn()} />],
['group form', () => <ContactGroupForm individuals={[]} onSave={vi.fn()} onCancel={vi.fn()} />],
])('keeps toasts off the %s footer by registering it as a bottom bar', (_name, form) => {
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
() => ({ left: 0, top: window.innerHeight - 72, width: 600, height: 72, right: 600, bottom: window.innerHeight, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect,
);
try {
const { unmount } = render(form());
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('72px');
unmount();
expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('');
} finally {
rect.mockRestore();
}
});
describe('photos (P0-3)', () => {
// The same PHOTO as ContactCard/get returns it on each version.
const photo0_16_23: ContactMedia = { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' };
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ContactListItem } from '../contact-list-item';
import type { ContactCard } from '@/lib/jmap/types';
@@ -82,4 +82,24 @@ describe('ContactListItem', () => {
const { container } = render(<ContactListItem contact={contact} {...baseProps} density="extra-compact" />);
expect(container.querySelector('[data-testid="avatar"]') || container.querySelector('.rounded-full')).toBeNull();
});
it('opens the row menu on long-press without leaking the press state onto the row', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.useFakeTimers();
try {
const onLongPress = vi.fn();
const { container } = render(<ContactListItem contact={contact} {...baseProps} onLongPress={onLongPress} />);
const row = container.firstElementChild!;
const touchStart = new Event('touchstart', { bubbles: true });
Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] });
act(() => { row.dispatchEvent(touchStart); });
act(() => { vi.advanceTimersByTime(600); });
expect(onLongPress).toHaveBeenCalledWith(row);
const unknownProp = errors.mock.calls.some((args) => args.some((a) => String(a).includes('isPressed')));
expect(unknownProp).toBe(false);
} finally {
vi.useRealTimers();
errors.mockRestore();
}
});
});
@@ -42,6 +42,7 @@ function getRegisteredGroups(): CommandGroup[] {
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
import { useToastStore } from '@/stores/toast-store';
import { interceptLeave } from '@/lib/leave-guard';
export const alice: ContactCard = {
id: 'c-alice',
@@ -131,6 +132,44 @@ describe('ContactsApp', () => {
expect(useContactStore.getState().selectedContactId).toBe('c-bob');
});
it('adds the open contact to the selection on Ctrl+click, as Files does', () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('Bob Jones'), { ctrlKey: true });
expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-alice', 'c-bob']);
});
it('selects from the open contact on Shift+click', () => {
const carol: ContactCard = { ...bob, id: 'c-carol', name: { full: 'Carol King' }, emails: { e0: { address: 'carol@example.com' } } };
renderContacts([alice, bob, carol]);
fireEvent.click(screen.getByText('Bob Jones'));
fireEvent.click(screen.getByText('Carol King'), { shiftKey: true });
expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-bob', 'c-carol']);
});
it('has the rail and tab bar ask before they leave a contact form with changes', async () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
// A rail link wired as the real one is: interceptLeave first.
const navigated = vi.fn();
const link = document.createElement('a');
link.addEventListener('click', (e) => { if (!interceptLeave(e)) navigated(); });
document.body.appendChild(link);
try {
act(() => { link.click(); });
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('form.discard_title')).toBeInTheDocument();
expect(navigated).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'form.discard' }));
await waitFor(() => expect(navigated).toHaveBeenCalledOnce());
} finally {
link.remove();
}
});
it('leaves an untouched contact form without asking', () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
@@ -0,0 +1,113 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ContactCard } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// The delete confirms must name what goes (R3), so this file's t() shows the
// values it was given: "delete_named_title {"name":"Alice Smith"}".
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key} ${JSON.stringify(values)}` : key,
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
useParams: () => ({ locale: 'en' }),
usePathname: () => '/en/contacts',
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-sidebar-apps', () => ({
useSidebarApps: () => ({
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
}),
}));
import { ContactsApp } from '../contacts-app';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
const alice: ContactCard = {
id: 'c-alice',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
};
const bob: ContactCard = {
id: 'c-bob',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
emails: { e0: { address: 'bob@example.com' } },
};
const team = {
id: 'g-team',
kind: 'group',
addressBookIds: {},
name: { full: 'Team' },
members: {},
} as unknown as ContactCard;
const FAKE_CLIENT = { supportsPrincipals: () => false } as unknown as IJMAPClient;
function renderContacts(selected: string[] = []) {
act(() => {
useAuthStore.setState({ isAuthenticated: true, client: FAKE_CLIENT, isLoading: false });
useContactStore.setState({
contacts: [alice, bob, team],
addressBooks: [],
selectedContactId: null,
selectedContactIds: new Set(selected),
searchQuery: '',
supportsSync: false,
contactsLoaded: true,
});
});
return render(<ContactsApp />);
}
describe('Contacts delete confirms', () => {
afterEach(() => {
act(() => {
useAuthStore.setState({ isAuthenticated: false, client: null });
useContactStore.setState({ contacts: [], selectedContactId: null, selectedContactIds: new Set() });
});
});
it('names the contact and says what is lost', async () => {
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.delete'));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('delete_named_title {"name":"Alice Smith"}')).toBeInTheDocument();
expect(within(dialog).getByText('delete_details_message')).toBeInTheDocument();
});
it('counts the contacts when several are selected', async () => {
renderContacts(['c-alice', 'c-bob']);
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByRole('menuitem', { name: 'bulk.delete' }));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('bulk.delete_confirm {"count":2}')).toBeInTheDocument();
expect(within(dialog).getByText('delete_details_message')).toBeInTheDocument();
});
it('names the group and says its contacts are kept', async () => {
renderContacts();
fireEvent.contextMenu(screen.getAllByText('Team')[0]);
fireEvent.click(screen.getByText('groups.delete'));
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('delete_named_title {"name":"Team"}')).toBeInTheDocument();
expect(within(dialog).getByText('groups.delete_message')).toBeInTheDocument();
});
});
@@ -38,6 +38,20 @@ function renderSidebar(onComposeGroup = vi.fn()) {
return onComposeGroup;
}
// First in the file: React warns about an unknown DOM prop only once per run.
describe('ContactsSidebar — row props', () => {
it('keeps the long-press state off the row element', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
renderSidebar();
const unknownProp = errors.mock.calls.some((args) => args.some((a) => String(a).includes('isPressed')));
expect(unknownProp).toBe(false);
} finally {
errors.mockRestore();
}
});
});
describe('ContactsSidebar — compose to group', () => {
beforeEach(() => vi.clearAllMocks());
@@ -45,9 +59,9 @@ describe('ContactsSidebar — compose to group', () => {
renderSidebar();
fireEvent.contextMenu(screen.getByText('Team'));
expect(screen.getByText('groups.send_email')).toBeInTheDocument();
// and the existing Edit/Delete entries still render
// and the existing Edit/Delete entries still render, Delete named as in the group's own ⋮
expect(screen.getByText('groups.edit')).toBeInTheDocument();
expect(screen.getByText('form.delete')).toBeInTheDocument();
expect(screen.getByText('groups.delete')).toBeInTheDocument();
});
it('calls onComposeGroup(groupId, field) when a To/Cc/Bcc item is clicked', () => {
@@ -30,7 +30,7 @@ export function AddressBookPickerDialog({ isOpen, onClose, addressBooks, current
isOpen={isOpen}
onClose={onClose}
title={t("address_books.move_title")}
description={t("bulk.adding_contacts", { count })}
description={t("bulk.moving_contacts", { count })}
onSubmit={(e) => {
e.preventDefault();
if (!choice) return;
+30 -13
View File
@@ -10,6 +10,7 @@ import { Avatar } from "@/components/ui/avatar";
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
import type { ContactSaveOptions } from "@/lib/contact-media";
import { cn } from "@/lib/utils";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
interface EmailEntry {
@@ -398,6 +399,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [keywordsStr, setKeywordsStr] = useState(
contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : ""
);
// Text typed in the Categories field but not added with Enter yet: Save
// adds it, so it isn't lost.
const [categoryDraft, setCategoryDraft] = useState("");
const [note, setNote] = useState(
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
@@ -455,11 +459,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const fields = JSON.stringify([
prefix, givenName, additionalName, surname, suffix, nickname, emails, phones, organization,
department, isOrg, jobTitle, role, addresses, onlineServices, anniversaries, personalInfoEntries,
keywordsStr, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
]);
const [initialFields] = useState(fields);
const dirty = fields !== initialFields || photoEdit.kind !== "unchanged";
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
const footerRef = useBottomControls();
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -614,11 +619,14 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
});
const keywordsMap: Record<string, boolean> = {};
if (keywordsStr.trim()) {
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
keywordsMap[k] = true;
});
}
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
keywordsMap[k] = true;
});
// A typed category that matches an existing one takes its spelling.
categoryDraft.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
const existing = (allKeywords || []).find(kw => kw.toLowerCase() === k.toLowerCase());
keywordsMap[existing ?? k] = true;
});
// Only send `kind` when this form owns the answer: switching a card between
// person and organization. Leave other kinds (group, location, ...) untouched.
@@ -696,7 +704,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 max-w-3xl">
<div className="@container px-6 py-4 max-w-3xl">
{error && (
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md mb-4">
{error}
@@ -816,10 +824,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
) : (
<>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
{/* Two columns in a narrow pane (title and first name, last name and
suffix), one row once there is room, so no name gets cut. */}
<div className="grid grid-cols-2 @lg:grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="@lg:w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("given_name")}</label>
@@ -831,7 +841,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="@lg:w-20" />
</div>
</div>
{/* Either name is enough (or an organization), so neither is starred. */}
@@ -1126,6 +1136,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<CategoryComboBox
keywordsStr={keywordsStr}
onChange={setKeywordsStr}
inputValue={categoryDraft}
onInputChange={setCategoryDraft}
allKeywords={allKeywords || []}
placeholder={t("categories_placeholder")}
hint={t("categories_hint")}
@@ -1186,7 +1198,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
{/* Toasts sit above Save and Cancel, not on them. */}
<div ref={footerRef} className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
{t("cancel")}
</Button>
@@ -1201,6 +1214,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
function CategoryComboBox({
keywordsStr,
onChange,
inputValue,
onInputChange: setInputValue,
allKeywords,
placeholder,
hint,
@@ -1208,6 +1223,9 @@ function CategoryComboBox({
}: {
keywordsStr: string;
onChange: (value: string) => void;
/** The text typed but not added yet; the form adds it on Save. */
inputValue: string;
onInputChange: (value: string) => void;
allKeywords: string[];
placeholder: string;
hint: string;
@@ -1215,7 +1233,6 @@ function CategoryComboBox({
}) {
const t = useTranslations("contacts.form");
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string
@@ -1243,7 +1260,7 @@ function CategoryComboBox({
const next = [...currentKeywords, trimmed].join(", ");
onChange(next);
setInputValue("");
}, [currentKeywords, onChange]);
}, [currentKeywords, onChange, setInputValue]);
const removeKeyword = useCallback((keyword: string) => {
const next = currentKeywords.filter(k => k !== keyword).join(", ");
+4 -1
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
@@ -44,6 +45,7 @@ export function ContactGroupForm({
const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") }));
const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members;
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
const footerRef = useBottomControls();
const filteredIndividuals = useMemo(() => {
if (!memberSearch) return individuals;
@@ -195,7 +197,8 @@ export function ContactGroupForm({
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
{/* Toasts sit above Save and Cancel, not on them. */}
<div ref={footerRef} className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
{t("form.cancel")}
</Button>
+1 -1
View File
@@ -67,7 +67,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
return (
<div
ref={rowRef}
{...(onLongPress ? longPress : {})}
{...(onLongPress ? longPress.handlers : {})}
role="option"
aria-selected={isSelected || isChecked}
tabIndex={isFocused ? 0 : -1}
+6
View File
@@ -720,11 +720,17 @@ export function ContactList({
density={density}
selectedContactIds={selectedContactIds}
onClick={(e) => {
// The open contact counts as selected, as a clicked file does
// in Files: Ctrl/⌘+click and Shift+click build on it.
const startFromOpen = !hasSelection && !!selectedContactId
&& selectedContactId !== contact.id && sortedIds.includes(selectedContactId);
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (startFromOpen) onToggleSelection(selectedContactId);
onToggleSelection(contact.id);
} else if (e.shiftKey) {
e.preventDefault();
if (startFromOpen) onToggleSelection(selectedContactId);
onSelectRangeContacts(contact.id, sortedIds);
} else {
if (hasSelection) onClearSelection();
+42 -38
View File
@@ -52,6 +52,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useRegisterCommands } from "@/lib/command-registry";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import { useLeaveGuard } from "@/lib/leave-guard";
import { useDeleteCategory } from "@/hooks/use-delete-category";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
@@ -172,18 +173,23 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
useEffect(() => {
if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false);
}, [view, setEditorDirty]);
const askDiscard = useCallback(() => confirmDialog({
title: t("form.discard_title"),
message: t("form.discard_message"),
confirmText: t("form.discard"),
cancelText: t("form.keep_editing"),
variant: "destructive",
}), [confirmDialog, t]);
const confirmDiscard = useCallback(async () => {
if (!editorDirtyRef.current) return true;
const discard = await confirmDialog({
title: t("form.discard_title"),
message: t("form.discard_message"),
confirmText: t("form.discard"),
cancelText: t("form.keep_editing"),
variant: "destructive",
});
const discard = await askDiscard();
if (discard) setEditorDirty(false);
return discard;
}, [confirmDialog, t, setEditorDirty]);
}, [askDiscard, setEditorDirty]);
// The rail and the phone tab bar ask too, before they leave Contacts. Only
// ask there: leaving unmounts the form, and clearing it first would let the
// Back guard below take its history step back over the navigation.
useLeaveGuard(() => editorDirtyRef.current, askDiscard);
const guard = <A extends unknown[]>(action: (...args: A) => unknown) => (...args: A): void => {
if (!editorDirtyRef.current) {
action(...args);
@@ -529,14 +535,31 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
setView("edit");
};
const deleteContactById = useCallback(async (contactId: string) => {
const confirmed = await confirmDialog({
title: t("delete_confirm_title"),
message: t("delete_confirm"),
// R3: the confirm names what goes ("Delete Alice Smith?", "Delete 3
// contacts?") and says what is lost with it.
const confirmDeleteContacts = useCallback((ids: string[]) => {
const only = ids.length === 1 ? contacts.find((c) => c.id === ids[0]) : undefined;
const name = only ? getContactDisplayName(only).trim() : "";
return confirmDialog({
title: name ? t("delete_named_title", { name }) : t("bulk.delete_confirm", { count: ids.length }),
message: t("delete_details_message"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
}, [contacts, confirmDialog, t]);
const confirmDeleteGroup = useCallback((groupId: string) => {
const group = contacts.find((c) => c.id === groupId);
const name = group ? getContactDisplayName(group).trim() : "";
return confirmDialog({
title: name ? t("delete_named_title", { name }) : t("groups.delete_confirm_title"),
message: t("groups.delete_message"),
confirmText: t("form.delete"),
variant: "destructive",
});
}, [contacts, confirmDialog, t]);
const deleteContactById = useCallback(async (contactId: string) => {
if (!(await confirmDeleteContacts([contactId]))) return;
try {
const deleted = supportsSync && client
@@ -550,7 +573,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
console.error('Failed to delete contact:', error);
toast.error(t("toast.error_delete"));
}
}, [confirmDialog, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
}, [confirmDeleteContacts, t, supportsSync, client, deleteContact, deleteLocalContact, selectedContactId]);
const handleDelete = async () => {
if (!selectedContact) return;
@@ -773,13 +796,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}, [openComposeInApp]);
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
message: t("groups.delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
if (!(await confirmDeleteGroup(groupId))) return;
try {
await deleteGroup(supportsSync && client ? client : null, groupId);
@@ -793,18 +810,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
console.error('Failed to delete group:', error);
toast.error(t("toast.error_delete"));
}
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
}, [confirmDeleteGroup, deleteGroup, supportsSync, client, selectedGroupId, t]);
const handleDeleteGroup = async () => {
if (!selectedGroup) return;
const confirmed = await confirmDialog({
title: t("groups.delete_confirm_title"),
message: t("groups.delete_confirm"),
confirmText: t("form.delete"),
variant: "destructive",
});
if (!confirmed) return;
if (!(await confirmDeleteGroup(selectedGroup.id))) return;
try {
await deleteGroup(supportsSync && client ? client : null, selectedGroup.id);
@@ -855,13 +865,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const handleBulkDelete = async () => {
if (selectedContactIds.size === 0) return;
const confirmed = await confirmDialog({
title: t("bulk.delete_confirm_title"),
message: t("bulk.delete_confirm", { count: selectedContactIds.size }),
confirmText: t("bulk.delete"),
variant: "destructive",
});
if (!confirmed) return;
if (!(await confirmDeleteContacts(Array.from(selectedContactIds)))) return;
try {
if (!(await bulkDeleteContacts(
@@ -1193,7 +1197,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}))}
onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => {
const ok = await confirmDialog({
title: t("address_books.delete"),
title: t("address_books.delete_title"),
message: t("address_books.confirm_delete", { name: book.name }),
variant: "destructive",
confirmText: t("address_books.delete"),
+2 -2
View File
@@ -738,7 +738,7 @@ export function ContactsSidebar({
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("form.delete")}
label={t("groups.delete")}
onClick={() => {
closeGroupContextMenu();
onDeleteGroup?.(groupContextMenu.data!.id);
@@ -788,7 +788,7 @@ function SidebarRow({
ref={rowRef}
{...rowProps}
{...dropProps}
{...(onOpenMenu ? longPress : {})}
{...(onOpenMenu ? longPress.handlers : {})}
onContextMenu={onContextMenu}
className={cn(
// 32px rows, 44px on touch (R1).
@@ -0,0 +1,27 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
let appLocale = 'ar';
vi.mock('@/i18n/detect-locale', () => ({ getEffectiveLocale: () => appLocale }));
import { FileSize, formatFullDate, formatShortDate } from '../file-item-parts';
// The browser here is en-US; the app's language must win.
describe('Files list dates and sizes', () => {
afterEach(() => { appLocale = 'ar'; });
it('writes dates in the app language, not the browser one', () => {
const now = new Date('2026-10-27T12:00:00Z');
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/مارس/);
expect(formatFullDate('2026-03-05T10:00:00Z')).toMatch(/مارس/);
appLocale = 'de';
expect(formatShortDate('2026-03-05T10:00:00Z', now)).toMatch(/März/);
});
it('keeps the number before the unit in a right-to-left layout', () => {
const { container } = render(<div dir="rtl"><FileSize bytes={6861} /></div>);
const size = container.querySelector('bdi')!;
expect(size).toHaveAttribute('dir', 'ltr');
expect(size.textContent).toBe('6.7 KB');
});
});
+5 -5
View File
@@ -4,12 +4,12 @@ import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Share2, Star, StarOff, FolderOpen, Eye, SquarePen, Files } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils";
import { cn } from "@/lib/utils";
import type { FileResource } from "@/stores/file-store";
import type { FileResourceView } from "@/lib/plugin-types";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
FileSize, ResourceIcon, Thumbnail, canShowThumbnail, formatFullDate, type IconOptions,
} from "@/components/files/file-item-parts";
interface FileDetailsPanelProps {
@@ -91,7 +91,7 @@ export function FileDetailsPanel({
<p className="text-sm text-muted-foreground">{t("folder_summary", { folders, files })}</p>
</div>
<dl className="space-y-3 text-sm">
<Row label={t("size")} value={formatFileSize(totalSize(items))} />
<Row label={t("size")} value={<FileSize bytes={totalSize(items)} />} />
{selection.length <= 1 && <Row label={t("path")} value={path} mono />}
</dl>
{selection.length <= 1 && (
@@ -160,7 +160,7 @@ export function FileDetailsPanel({
label={t("type")}
value={resource.isDirectory || resource.isSymlink ? kindOf(resource, t) : (resource.contentType || kindOf(resource, t))}
/>
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={formatFileSize(resource.contentLength)} />}
{!resource.isDirectory && !resource.isSymlink && <Row label={t("size")} value={<FileSize bytes={resource.contentLength} />} />}
{resource.lastModified && <Row label={t("modified")} value={formatFullDate(resource.lastModified)} />}
<Row label={t("sharing")} value={sharing} />
<Row label={t("path")} value={path} mono />
@@ -170,7 +170,7 @@ export function FileDetailsPanel({
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
function Row({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
+23 -4
View File
@@ -3,8 +3,10 @@
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Share2, Users } from "@/components/icons";
import { cn } from "@/lib/utils";
import { cn, formatFileSize } from "@/lib/utils";
import { getEffectiveTimeZone } from "@/lib/timezone";
import { getEffectiveLocale } from "@/i18n/detect-locale";
import { useSettingsStore } from "@/stores/settings-store";
import type { FileResource } from "@/stores/file-store";
import { getFileIconByName, getFolderIcon, getSymlinkIcon, isImageFile, type FileIconSize } from "@/components/files/file-icons";
@@ -124,17 +126,33 @@ export function ShareBadge({ resource, t, className }: {
return null;
}
/**
* A size that keeps its number before its unit ("6.7 KB") in a right-to-left
* layout too, where the bidi algorithm would otherwise show "KB 6.7".
*/
export function FileSize({ bytes }: { bytes: number }) {
return <bdi dir="ltr">{formatFileSize(bytes)}</bdi>;
}
/** Dates follow the app's language and 12/24-hour setting, as Mail's list does, not the browser's. */
function dateLocale(): { locale: string; hour12: boolean } {
const locale = getEffectiveLocale();
return { locale: locale === "en" ? "en-US" : locale, hour12: useSettingsStore.getState().timeFormat === "12h" };
}
/** Full date and time, for tooltips and the details panel. */
export function formatFullDate(value: string): string {
if (!value) return "";
try {
return new Date(value).toLocaleString(undefined, {
const { locale, hour12 } = dateLocale();
return new Date(value).toLocaleString(locale, {
timeZone: getEffectiveTimeZone(),
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12,
});
} catch {
return value;
@@ -149,12 +167,13 @@ export function formatShortDate(value: string, now = new Date()): string {
const timeZone = getEffectiveTimeZone();
const dayKey = (d: Date) => d.toLocaleDateString("en-CA", { timeZone });
try {
const { locale, hour12 } = dateLocale();
if (dayKey(date) === dayKey(now)) {
return date.toLocaleTimeString(undefined, { timeZone, hour: "2-digit", minute: "2-digit" });
return date.toLocaleTimeString(locale, { timeZone, hour: "2-digit", minute: "2-digit", hour12 });
}
const sameYear = date.toLocaleDateString("en-CA", { timeZone, year: "numeric" })
=== now.toLocaleDateString("en-CA", { timeZone, year: "numeric" });
return date.toLocaleDateString(undefined, {
return date.toLocaleDateString(locale, {
timeZone,
day: "numeric",
month: "short",
+1 -1
View File
@@ -175,7 +175,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
<span className="block truncate">{node.name}</span>
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</span>
<bdi dir="ltr" className="shrink-0 text-xs text-muted-foreground tabular-nums">{formatFileSize(node.size)}</bdi>
</button>
);
};
+5 -5
View File
@@ -3,11 +3,11 @@
import { useRef } from "react";
import { useTranslations } from "next-intl";
import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons";
import { cn, formatFileSize } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
import type { FileResource } from "@/stores/file-store";
import {
ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
type IconOptions,
} from "@/components/files/file-item-parts";
import type { SortDir, SortKey } from "@/components/files/files-header";
@@ -271,7 +271,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
<div className="text-xs text-muted-foreground truncate">
{note && <>{note} · </>}
{formatShortDate(resource.lastModified)}
{!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}</>}
{!resource.isDirectory && !resource.isSymlink && <> · <FileSize bytes={resource.contentLength} /></>}
</div>
)}
</div>
@@ -280,7 +280,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
{!touch && formatShortDate(resource.lastModified)}
</div>
<div role="gridcell" className="hidden sm:block text-muted-foreground tabular-nums text-end">
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : formatFileSize(resource.contentLength))}
{!touch && (resource.isDirectory || resource.isSymlink ? "—" : <FileSize bytes={resource.contentLength} />)}
</div>
<div role="gridcell" className="flex items-center justify-end gap-0.5 w-[4.75rem]">
{!touch && !resource.isDirectory && !resource.isSymlink && (
@@ -482,7 +482,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
</button>
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
<span className="truncate">
{resource.isSymlink ? "" : `${formatFileSize(resource.contentLength)} · `}{formatShortDate(resource.lastModified)}
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
</span>
<ShareBadge resource={resource} t={t} className="w-3 h-3" />
</div>
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { toast } from '@/stores/toast-store';
@@ -82,7 +82,8 @@ describe('FilterRuleModal incomplete action', () => {
expect(onSave).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
expect(screen.getByRole('alert').textContent).toBe('validation_empty_actions');
// The row is there; what it misses is the folder, not "an action".
expect(screen.getByRole('alert').textContent).toBe('validation_action_folder');
const folder = screen.getByRole('combobox', { name: 'move_to_folder' });
expect(folder.getAttribute('aria-invalid')).toBe('true');
@@ -92,6 +93,44 @@ describe('FilterRuleModal incomplete action', () => {
expect(onSave.mock.calls[0][0].actions).toEqual([{ type: 'move', mailboxId: 'news', value: 'News' }]);
});
it('marks the empty condition too, and focuses the first field to fix', async () => {
const onSave = vi.fn();
render(<FilterRuleModal mailboxes={mailboxes} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
const value = screen.getByRole('textbox', { name: 'condition_value' });
const folder = screen.getByRole('combobox', { name: 'move_to_folder' });
expect(value.getAttribute('aria-invalid')).toBe('true');
expect(folder.getAttribute('aria-invalid')).toBe('true');
expect(screen.getAllByRole('alert').map((a) => a.textContent)).toEqual([
'validation_empty_conditions',
'validation_action_folder',
]);
// The condition comes first on the form, so focus goes there, not to
// the folder.
await waitFor(() => expect(document.activeElement).toBe(value));
fireEvent.change(value, { target: { value: 'news@example.com' } });
expect(value.getAttribute('aria-invalid')).toBe('false');
expect(onSave).not.toHaveBeenCalled();
});
it('does not save a second action left without its folder', () => {
const onSave = vi.fn();
render(<FilterRuleModal mailboxes={mailboxes} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } });
fireEvent.change(screen.getByPlaceholderText('value_placeholder_multi'), { target: { value: 'news@example.com' } });
fireEvent.change(screen.getByRole('combobox', { name: 'move_to_folder' }), { target: { value: 'news' } });
fireEvent.click(screen.getByText('add_action'));
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(onSave).not.toHaveBeenCalled();
const folders = screen.getAllByRole('combobox', { name: 'move_to_folder' });
expect(folders.map((f) => f.getAttribute('aria-invalid'))).toEqual(['false', 'true']);
expect(screen.getByRole('alert').textContent).toBe('validation_action_folder');
});
it('starts a changed action without a folder too, like a new row', () => {
render(<FilterRuleModal mailboxes={mailboxes} onSave={vi.fn()} onClose={() => {}} />);
fireEvent.change(screen.getByRole('combobox', { name: 'actions' }), { target: { value: 'copy' } });
@@ -0,0 +1,68 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { generateScript } from '@/lib/sieve/generator';
import { SieveEditorModal } from '../sieve-editor-modal';
const visualRule: FilterRule = {
id: 'r1',
name: 'Receipts',
enabled: true,
matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'shop@example.com' }],
actions: [{ type: 'move', value: 'Receipts' }],
stopProcessing: false,
};
const handWritten = 'require ["fileinto"];\nif header :contains "Subject" "x" {\n fileinto "X";\n}\n';
function renderEditor(content: string, onSave = vi.fn()) {
render(
<SieveEditorModal content={content} onSave={onSave} onClose={() => {}} onValidate={async () => ({ isValid: true })} />,
);
return onSave;
}
// The Save button stays "Save": it never turns into "Save anyway" and never
// needs a second click. Only a save that would drop the visual builder's
// rules asks, once, in a confirm dialog.
describe('SieveEditorModal save', () => {
it('saves at once when the visual rules are not lost', () => {
const onSave = renderEditor(handWritten);
fireEvent.change(screen.getByLabelText('script_content'), { target: { value: `${handWritten}# note\n` } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith(`${handWritten}# note\n`);
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('asks once before a script the builder cannot read replaces the visual rules', async () => {
const onSave = renderEditor(generateScript([visualRule]));
fireEvent.change(screen.getByLabelText('script_content'), { target: { value: handWritten } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
const dialog = await screen.findByRole('alertdialog');
expect(dialog.textContent).toContain('overwrite_title');
expect(onSave).not.toHaveBeenCalled();
// The editor's own button keeps its label.
expect(screen.queryByText('confirm_save')).toBeNull();
fireEvent.click(screen.getAllByRole('button', { name: 'save' }).at(-1)!);
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave).toHaveBeenCalledWith(handWritten);
});
it('does not save when the user keeps editing', async () => {
const onSave = renderEditor(generateScript([visualRule]));
fireEvent.change(screen.getByLabelText('script_content'), { target: { value: handWritten } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
fireEvent.click(await screen.findByRole('button', { name: 'keep_editing' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(onSave).not.toHaveBeenCalled();
});
});
+61 -14
View File
@@ -260,7 +260,7 @@ export function FilterRuleModal({
})
.map((c) => (c.field === "header" ? { ...c, headerName: (c.headerName ?? "").trim() } : c)), [conditions]);
const validActions = useMemo(() => actions
const resolvedActions = useMemo(() => actions
.map((a) => {
if (!ACTIONS_WITH_MAILBOX.has(a.type)) return a;
// Store the folder id next to the path and refresh the path from it,
@@ -268,8 +268,17 @@ export function FilterRuleModal({
const mailboxId = mailboxIdFor(a);
const path = mailboxId ? mailboxPathMap.get(mailboxId) : undefined;
return mailboxId ? { ...a, mailboxId, value: path ?? a.value } : a;
})
.filter((a) => !ACTIONS_WITH_VALUE.has(a.type) || a.value?.trim()), [actions, mailboxIdFor, mailboxPathMap]);
}), [actions, mailboxIdFor, mailboxPathMap]);
// A row still missing its folder, tag, address or message.
const actionIncomplete = useMemo(
() => resolvedActions.map((a) => ACTIONS_WITH_VALUE.has(a.type) && !a.value?.trim()),
[resolvedActions],
);
const validActions = useMemo(
() => resolvedActions.filter((_, i) => !actionIncomplete[i]),
[resolvedActions, actionIncomplete],
);
const firstIncompleteAction = resolvedActions.find((_, i) => actionIncomplete[i]);
// Old mail can only be sorted by what the client can check the way Sieve
// does. Until an action is complete (a Move still without its folder), only
@@ -298,7 +307,17 @@ export function FilterRuleModal({
: validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? ""))
? t("validation_header_name")
: null,
actions: validActions.length === 0 ? t("validation_empty_actions") : null,
// A row that is there but unfinished says what it misses; "at least one
// action" is left for when no row has a type at all.
actions: firstIncompleteAction
? t(ACTIONS_WITH_MAILBOX.has(firstIncompleteAction.type)
? "validation_action_folder"
: firstIncompleteAction.type === "add_label"
? "validation_action_tag"
: firstIncompleteAction.type === "forward"
? "validation_action_forward"
: "validation_action_reject")
: validActions.length === 0 ? t("validation_empty_actions") : null,
period: !periodOn
? null
// A field the browser holds half filled in (a date without its time)
@@ -313,6 +332,14 @@ export function FilterRuleModal({
};
const hasErrors = Object.values(errors).some(Boolean);
const shown = (error: string | null) => (showErrors ? error : null);
// The fields the shown errors are about: every empty value while no
// condition has one, a header name Sieve refuses, an unfinished action.
const valueMissing = (c: FilterCondition) =>
!!shown(errors.conditions) && validConditions.length === 0 && !takesNoValue(c) && isConditionValueEmpty(c.value);
const headerInvalid = (c: FilterCondition) =>
!!shown(errors.conditions) && c.field === "header" && !isConditionValueEmpty(c.value) &&
!isValidSieveHeaderName((c.headerName ?? "").trim());
const actionInvalid = (index: number) => !!shown(errors.actions) && actionIncomplete[index];
const handleSave = useCallback(() => {
const halfFilled = periodOn &&
@@ -535,7 +562,10 @@ export function FilterRuleModal({
updateCondition(index, { headerName: e.target.value })
}
placeholder={t("header_name")}
className="w-28"
aria-label={t("header_name")}
aria-invalid={headerInvalid(condition)}
data-rule-error={headerInvalid(condition)}
className={cn("w-28", headerInvalid(condition) && "border-destructive")}
/>
)}
@@ -598,7 +628,11 @@ export function FilterRuleModal({
? t("attachment_type_placeholder")
: t("value_placeholder_multi")
}
className="flex-1 min-w-[120px]"
aria-label={t("condition_value")}
aria-invalid={valueMissing(condition)}
aria-describedby={valueMissing(condition) ? "rule-conditions-error" : undefined}
data-rule-error={valueMissing(condition)}
className={cn("flex-1 min-w-[120px]", valueMissing(condition) && "border-destructive")}
type={condition.field === "size" ? "number" : "text"}
/>
)}
@@ -622,7 +656,7 @@ export function FilterRuleModal({
<Plus className="w-3.5 h-3.5" />
{t("add_condition")}
</button>
<FieldError text={shown(errors.conditions)} />
<FieldError id="rule-conditions-error" text={shown(errors.conditions)} />
</div>
<div>
@@ -656,6 +690,7 @@ export function FilterRuleModal({
{ACTIONS_WITH_MAILBOX.has(action.type) && (() => {
const selectedId = mailboxIdFor(action);
const invalid = actionInvalid(index);
const missing = !!selectedId && !mailboxPathMap.has(selectedId);
return (
<select
@@ -667,10 +702,11 @@ export function FilterRuleModal({
value: id ? (mailboxPathMap.get(id) ?? "") : "",
});
}}
className={cn(selectClass, "flex-1 min-w-[140px]", shown(errors.actions) && !selectedId && "border-destructive")}
className={cn(selectClass, "flex-1 min-w-[140px]", invalid && "border-destructive")}
aria-label={t("move_to_folder")}
aria-invalid={!!shown(errors.actions) && !selectedId}
data-rule-error={!!shown(errors.actions) && !selectedId}
aria-invalid={invalid}
aria-describedby={invalid ? "rule-actions-error" : undefined}
data-rule-error={invalid}
>
<option value="">{t("move_to_folder")}</option>
{missing && <option value={selectedId}>{action.value}</option>}
@@ -690,7 +726,11 @@ export function FilterRuleModal({
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("forward_placeholder")}
type="email"
className="flex-1 min-w-[180px]"
aria-label={t("forward_to")}
aria-invalid={actionInvalid(index)}
aria-describedby={actionInvalid(index) ? "rule-actions-error" : undefined}
data-rule-error={actionInvalid(index)}
className={cn("flex-1 min-w-[180px]", actionInvalid(index) && "border-destructive")}
/>
<label className="flex items-center gap-1.5 text-sm text-foreground cursor-pointer">
<input
@@ -709,7 +749,11 @@ export function FilterRuleModal({
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
placeholder={t("reject_placeholder")}
className="flex-1 min-w-[180px]"
aria-label={t("reject_message")}
aria-invalid={actionInvalid(index)}
aria-describedby={actionInvalid(index) ? "rule-actions-error" : undefined}
data-rule-error={actionInvalid(index)}
className={cn("flex-1 min-w-[180px]", actionInvalid(index) && "border-destructive")}
/>
)}
@@ -717,8 +761,11 @@ export function FilterRuleModal({
<select
value={action.value || ""}
onChange={(e) => updateAction(index, { value: e.target.value })}
className={`${selectClass} flex-1 min-w-[140px]`}
className={cn(selectClass, "flex-1 min-w-[140px]", actionInvalid(index) && "border-destructive")}
aria-label={t("label_placeholder")}
aria-invalid={actionInvalid(index)}
aria-describedby={actionInvalid(index) ? "rule-actions-error" : undefined}
data-rule-error={actionInvalid(index)}
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
@@ -756,7 +803,7 @@ export function FilterRuleModal({
<Plus className="w-3.5 h-3.5" />
{t("add_action")}
</button>
<FieldError text={shown(errors.actions)} />
<FieldError id="rule-actions-error" text={shown(errors.actions)} />
</div>
<div className="flex items-center gap-2">
+29 -17
View File
@@ -1,11 +1,20 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { AlertTriangle, CheckCircle, Loader2 } from "@/components/icons";
import { useDiscardChanges } from "@/components/settings/use-discard-changes";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { parseScript } from "@/lib/sieve/parser";
/** Rules made in the visual builder (not external or opaque blocks). */
function hasVisualRules(script: string): boolean {
const parsed = parseScript(script);
return !parsed.isOpaque && parsed.rules.some((r) => !r.origin || r.origin === "bulwark");
}
interface SieveEditorModalProps {
content: string;
@@ -21,13 +30,13 @@ export function SieveEditorModal({
onValidate,
}: SieveEditorModalProps) {
const t = useTranslations("settings.filters.sieve_editor");
const tSettings = useTranslations("settings");
const [script, setScript] = useState(content);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState<{
isValid: boolean;
errors?: string[];
} | null>(null);
const [showSaveWarning, setShowSaveWarning] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dirty = script !== content;
@@ -53,13 +62,23 @@ export function SieveEditorModal({
}
}, [script, onValidate, t]);
const handleSave = useCallback(() => {
if (!showSaveWarning) {
setShowSaveWarning(true);
return;
// The visual rules only go away when the saved script no longer carries
// them in a form the builder reads; only then does Save ask, once.
const startsWithVisualRules = useMemo(() => hasVisualRules(content), [content]);
const { dialogProps: overwriteProps, confirm: confirmOverwrite } = useConfirmDialog();
const handleSave = useCallback(async () => {
if (startsWithVisualRules && !hasVisualRules(script)) {
const ok = await confirmOverwrite({
title: t("overwrite_title"),
message: t("overwrite_message"),
confirmText: t("save"),
cancelText: tSettings("keep_editing"),
variant: "destructive",
});
if (!ok) return;
}
onSave(script);
}, [script, showSaveWarning, onSave]);
}, [startsWithVisualRules, script, confirmOverwrite, t, tSettings, onSave]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
@@ -105,8 +124,8 @@ export function SieveEditorModal({
<Button variant="outline" onClick={() => void requestClose()}>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!script.trim()}>
{showSaveWarning ? t("confirm_save") : t("save")}
<Button onClick={() => void handleSave()} disabled={!script.trim()}>
{t("save")}
</Button>
</>
}
@@ -137,7 +156,6 @@ export function SieveEditorModal({
onChange={(e) => {
setScript(e.target.value);
setValidationResult(null);
setShowSaveWarning(false);
}}
onKeyDown={handleKeyDown}
className="flex-1 bg-background text-foreground font-mono text-sm p-2 resize-none focus:outline-none leading-[1.5rem]"
@@ -174,17 +192,11 @@ export function SieveEditorModal({
)}
</div>
)}
{showSaveWarning && (
<div className="flex items-start gap-2 p-3 rounded-md bg-warning/10 border border-warning/20 text-sm text-warning">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>{t("save_warning")}</p>
</div>
)}
</div>
</Dialog>
{discardDialog}
<ConfirmDialog {...overwriteProps} />
</>
);
}
+12 -7
View File
@@ -130,7 +130,9 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t(section.title)}
</h3>
<div className={cn(section.wide ? "grid grid-cols-1 md:grid-cols-2 gap-2" : "space-y-2")}>
{/* A wide section's two columns line up with the columns of the
sections above it: the same 2rem gap as the panel grid. */}
<div className={cn(section.wide ? "grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2" : "space-y-2")}>
{rows.map((shortcut) => (
<ShortcutRow
key={`${shortcut.key}:${shortcut.description}`}
@@ -176,15 +178,18 @@ function ShortcutRow({
const keys = shortcutAlternatives(shortcutKey);
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-sm text-muted-foreground">{description}</span>
<div className="flex items-center gap-1.5 ms-4">
<div className="flex items-center justify-between gap-4 py-1.5">
<span className="min-w-0 text-sm text-muted-foreground">{description}</span>
{/* The description wraps first. A key never breaks inside ("Shift + ↓"),
and an alternative keeps its "or" with it ("n or c"); only a very
long list wraps, between alternatives. */}
<div className="flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 shrink-0 max-w-[75%]">
{keys.map((key, index) => (
<span key={index}>
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">{t("or")}</span>}
<span key={index} className="inline-flex items-center gap-1.5 whitespace-nowrap">
{index > 0 && <span className="text-muted-foreground/50 text-xs">{t("or")}</span>}
<kbd
className={cn(
"inline-flex items-center justify-center",
"inline-flex items-center justify-center whitespace-nowrap",
"px-2 py-0.5 text-xs font-mono font-medium",
"bg-muted border border-border rounded",
"text-foreground shadow-sm",
@@ -0,0 +1,52 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// An editor with unsaved changes registers a leave guard; the rail and the
// phone tab bar ask it before they leave the app (R5).
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/contacts',
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
}));
const { NavigationRail } = await import('../navigation-rail');
const { useUpdateStore } = await import('@/stores/update-store');
const { registerLeaveGuard } = await import('@/lib/leave-guard');
let off: (() => void) | null = null;
beforeEach(() => {
useUpdateStore.setState({ startPolling: vi.fn() } as never);
});
afterEach(() => { off?.(); off = null; });
describe.each([
['desktop rail', { orientation: 'vertical', collapsed: true }],
['phone tab bar', { orientation: 'horizontal', collapsed: false }],
] as const)('%s', (_name, props) => {
it('asks before leaving an editor with unsaved changes, and stays on "Keep editing"', async () => {
const confirm = vi.fn(async () => false);
off = registerLeaveGuard({ isDirty: () => true, confirm });
render(<NavigationRail {...props} />);
const mail = screen.getAllByRole('link', { name: 'mail' })[0];
const click = fireEvent.click(mail);
// fireEvent returns false when the default (navigating) was prevented.
expect(click).toBe(false);
expect(confirm).toHaveBeenCalledOnce();
});
it('leaves without asking when nothing is unsaved', () => {
const confirm = vi.fn(async () => false);
off = registerLeaveGuard({ isDirty: () => false, confirm });
render(<NavigationRail {...props} />);
fireEvent.click(screen.getAllByRole('link', { name: 'mail' })[0]);
expect(confirm).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -50,7 +50,7 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) {
<div className={cn('space-y-2', className)}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -60,7 +60,7 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) {
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
+1
View File
@@ -121,6 +121,7 @@ export function MobileHeader({
nesting one inside another is invalid markup. */
<div
data-mobile-mail-search=""
data-tour="search-input"
className={cn(
"flex-1 min-w-0 h-10 flex items-center rounded-full pe-1",
mountainView ? "mx-0 bg-transparent ps-2" : "mx-1 bg-muted ps-3"
+5 -2
View File
@@ -27,6 +27,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { interceptLeave } from "@/lib/leave-guard";
import { Avatar } from "@/components/ui/avatar";
import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
@@ -351,6 +352,7 @@ export function NavigationRail({
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
(e: React.MouseEvent) => {
if (interceptLeave(e)) return;
if (onNavigate) {
const intercepted = onNavigate(itemId);
if (intercepted !== false) {
@@ -421,7 +423,7 @@ export function NavigationRail({
<Link
key={`plugin-page:${page.pluginId}:${page.id}`}
href={pluginPageHref(page)}
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
title={page.pluginName}
className={cn(
@@ -517,6 +519,7 @@ export function NavigationRail({
<Link
href="/settings"
onClick={handleNavClick('settings')}
data-tour="nav-settings"
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -633,7 +636,7 @@ export function NavigationRail({
<Link
key={`plugin-page:${page.pluginId}:${page.id}`}
href={pluginPageHref(page)}
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
onClick={(e) => { if (interceptLeave(e)) return; if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
@@ -108,6 +108,24 @@ describe('Mail in the command palette', () => {
expect(ids).not.toContain('mark-read');
});
it("says whose a shared folder is and where a subfolder is, so two Inboxes don't look the same", async () => {
const shared = { ...folder('support:inbox', 'inbox'), isShared: true, accountName: 'Support', accountId: 'support', originalId: 'inbox' } as Mailbox;
const archive = folder('archive', 'archive', 'Archive');
const year = { ...folder('y2025', undefined, '2025'), parentId: 'archive' } as Mailbox;
const all = [...folders, shared, archive, year];
useEmailStore.setState({ mailboxes: all, selectedMailbox: 'work' } as never);
client.getMailboxes = vi.fn().mockResolvedValue(all);
let groups: CommandGroup[] = [];
render(<><MailApp /><Palette onGroups={(g) => { groups = g; }} /></>);
await screen.findByText('viewer', {}, { timeout: 5000 });
await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined());
// The test translator drops parameters; the place also goes in the keywords.
const where = (id: string) => groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === id)!.keywords?.slice(1);
expect(where('go-support:inbox')).toEqual(['Support']);
expect(where('go-inbox')).toEqual([]);
expect(where('go-y2025')?.[0]).toMatch(/archive/i);
});
it('moves the open message from "Move to…"', async () => {
let groups: CommandGroup[] = [];
render(<><MailApp /><Palette onGroups={(g) => { groups = g; }} /></>);
+18 -4
View File
@@ -1136,7 +1136,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
handlers: keyboardHandlers,
});
// Compose and search from the command palette (components/command-palette).
// Compose and search from the command palette (components/command-palette);
// the tour closes the phone's folder drawer and reader to point at the list
// and the tab bar.
useAppCommands({
compose: keyboardHandlers.onCompose,
search: () => {
@@ -1144,6 +1146,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
else keyboardHandlers.onFocusSearch?.();
},
'all-inboxes': () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox),
'show-list': () => {
if (!isMobile) return;
if (sidebarOpen) setSidebarOpen(false);
if (!showComposer && (activeView !== "list" || selectedEmail)) handleMobileBack();
},
}, isAuthenticated && !!client);
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
@@ -4215,6 +4222,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// "Go to" for every folder. Compose is the palette's own.
const paletteEmail = isScheduledView ? null : selectedEmail;
const hasPaletteEmail = () => !!paletteEmail;
const paletteMailboxesById = new Map(mailboxes.map((mb) => [mb.id, mb]));
const paletteMailboxName = (mb: Mailbox) => localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`));
useRegisterCommands(t('sidebar.mail'), [
{ id: 'reply', label: t('context_menu.reply'), icon: Reply, shortcut: 'r', when: hasPaletteEmail, run: () => void handleReply() },
{ id: 'reply-all', label: t('context_menu.reply_all'), icon: ReplyAll, shortcut: 'a', when: hasPaletteEmail, run: () => void handleReplyAll() },
@@ -4248,12 +4257,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
...mailboxes
.filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox)
.map((mb) => {
const name = localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`));
const name = paletteMailboxName(mb);
// Two Inboxes (yours and a shared Support account's), or "2025" in
// Archive and in Receipts: say whose, or where, it is.
const parent = mb.parentId ? paletteMailboxesById.get(mb.parentId) : undefined;
const where = [mb.isShared ? mb.accountName : undefined, parent ? paletteMailboxName(parent) : undefined]
.filter(Boolean).join(' / ');
return {
id: `go-${mb.id}`,
label: t('context_menu.go_to_folder', { folder: name }),
label: t('context_menu.go_to_folder', { folder: where ? `${name} · ${where}` : name }),
icon: Folder,
keywords: [name],
keywords: where ? [name, where] : [name],
run: () => void handleMailboxSelect(mb.id),
};
}),
@@ -55,7 +55,7 @@ describe('address books in Settings', () => {
expect(deletes).toHaveLength(1);
fireEvent.click(deletes[0]);
const dialog = within(screen.getByRole('alertdialog'));
const dialog = within(screen.getByRole('alertdialog', { name: 'delete_title' }));
expect(removeAddressBook).not.toHaveBeenCalled();
fireEvent.click(dialog.getByRole('button', { name: 'delete' }));
await waitFor(() => expect(removeAddressBook).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 'club' })));
@@ -72,3 +72,47 @@ describe('CalendarManagementSettings CalDAV fallback address', () => {
vi.restoreAllMocks();
});
});
// Phone: seven icons in a row hid the calendar names. The row actions are
// also in a ⋮ menu (right-click too), the same list as the icons, and every
// icon is a named button with the touch hit area.
describe('CalendarManagementSettings row actions', () => {
const work = {
id: 'work', name: 'Work', color: '#ff0000', isDefault: false, isShared: false, shareWith: null, sortOrder: 1,
myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
} as unknown as Calendar;
const home = { ...appleColored, color: '#00ff00', sortOrder: 0 } as Calendar;
beforeEach(() => {
useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never);
useCalendarStore.setState({ calendars: [home, work], icalSubscriptions: [], updateCalendar: vi.fn() } as never);
});
it('offers the row actions from a named ⋮ menu, Delete last and only where it applies', async () => {
render(<CalendarManagementSettings />);
const more = screen.getAllByRole('button', { name: 'row_actions' });
expect(more).toHaveLength(2);
fireEvent.click(more[1]); // Work, not the default calendar
const menu = await screen.findByRole('menu');
const items = Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent);
expect(items).toEqual(['edit', 'default_alerts', 'share', 'clear_events', 'move_up', 'move_down', 'delete']);
fireEvent.click(screen.getByRole('menuitem', { name: 'delete' }));
expect(await screen.findByRole('alertdialog')).toBeTruthy();
});
it('leaves Delete out of the default calendar menu', async () => {
render(<CalendarManagementSettings />);
fireEvent.click(screen.getAllByRole('button', { name: 'row_actions' })[0]);
const menu = await screen.findByRole('menu');
expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).map((el) => el.textContent)).not.toContain('delete');
});
it('names every row icon', () => {
render(<CalendarManagementSettings />);
for (const name of ['move_up', 'move_down', 'edit', 'default_alerts', 'share', 'clear_events', 'delete', 'change_color']) {
expect(screen.getAllByRole('button', { name }).length).toBeGreaterThan(0);
}
});
});
@@ -89,6 +89,17 @@ describe('KeywordSettings', () => {
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined();
});
// A list of "Edit" / "Delete" with nothing else said which tag a screen
// reader user was on; each button is named after its tag now, as a full
// size icon button (32px, 44px hit area on touch).
it('names each tag row button after its tag', () => {
render(<KeywordSettings />);
expect(screen.getAllByRole('button', { name: 'edit_tag' })).toHaveLength(DEFAULT_KEYWORDS.length);
const deletes = screen.getAllByRole('button', { name: 'delete_tag' });
expect(deletes).toHaveLength(DEFAULT_KEYWORDS.length);
expect(deletes[0].className).toContain('h-8 w-8');
});
it('shows edit form when edit button clicked', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
@@ -45,7 +45,8 @@ describe('deleting a tag', () => {
render(<KeywordSettings />);
const second = DEFAULT_KEYWORDS[1];
fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[1]);
// Each Delete is named after its tag (the mocked t returns the key).
fireEvent.click(screen.getAllByRole('button', { name: 'delete_tag' })[1]);
expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).not.toContain(second.id);
expect(screen.queryByRole('alertdialog')).toBeNull();
@@ -0,0 +1,106 @@
import React from 'react';
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import en from '@/locales/en/common.json';
// #40 / SET-27: in Settings the palette offered only "Go to Settings", and
// "out of office" found nothing. Each page the list shows is a command now,
// found by the words the settings search knows.
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string, values?: Record<string, string>) =>
`${ns ? `${ns}.` : ''}${key}${values?.name ? `(${values.name})` : ''}`,
useMessages: () => en,
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
vi.mock('@/hooks/use-media-query', () => ({
useIsDesktop: () => true,
useIsMobile: () => false,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: true }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => null }));
const { SettingsApp } = await import('../settings-app');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
const { useRegisteredCommandGroups, commandMatches } = await import('@/lib/command-registry');
type Groups = ReturnType<typeof useRegisteredCommandGroups>;
let groups: Groups = [];
function Probe() {
groups = useRegisteredCommandGroups();
return null;
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
// A server with Sieve and vacation, so Filters and Out of office are pages.
const client = new Proxy({}, {
get: (_t, prop) => {
if (prop === 'then') return undefined;
if (prop === 'getAccountId') return () => 'acc';
if (typeof prop === 'string' && prop.startsWith('supports')) return () => true;
return () => null;
},
});
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
function settingsCommands() {
const group = groups.find((g) => g.label === 'sidebar.settings');
return group?.commands ?? [];
}
function find(query: string) {
return settingsCommands().filter((c) => commandMatches(query, c.label, c.keywords));
}
describe('Settings commands in the palette', () => {
it('offers "Go to <page>" for the settings pages', async () => {
render(<><SettingsApp linkSegments={[]} /><Probe /></>);
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
const ids = settingsCommands().map((c) => c.id);
expect(ids).toEqual(expect.arrayContaining(['go-vacation', 'go-filters', 'go-security', 'go-account']));
expect(settingsCommands().find((c) => c.id === 'go-vacation')?.label).toBe('command_palette.go_to(settings.tabs.vacation)');
});
it('finds pages by the words the settings search knows', async () => {
render(<><SettingsApp linkSegments={[]} /><Probe /></>);
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
expect(find('out of office').map((c) => c.id)).toContain('go-vacation');
expect(find('app password').map((c) => c.id)).toContain('go-security');
expect(find('add rule').map((c) => c.id)).toContain('go-filters');
});
it('opens the page it names', async () => {
const tree = () => <><SettingsApp linkSegments={[]} /><Probe /></>;
const { rerender } = render(tree());
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
act(() => settingsCommands().find((c) => c.id === 'go-filters')!.run());
expect(localStorage.getItem('settings-active-tab')).toBe('filters');
// The palette reads the commands when it opens, after Settings drew
// the new page; a second render stands in for that here.
rerender(tree());
rerender(tree());
// The page that is open is not offered.
expect(settingsCommands().map((c) => c.id)).not.toContain('go-filters');
});
});
@@ -522,7 +522,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 me-1" />
{t('app_passwords.add')}
{tk('new')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{tk('description')}</p>
@@ -750,7 +750,7 @@ function PublicKeysSection() {
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 me-1" />
{t('app_passwords.add')}
{tk('add')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('encryption.description')}</p>
@@ -780,7 +780,7 @@ function PublicKeysSection() {
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !description.trim() || !publicKey.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('app_passwords.create')}
{tk('add_submit')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowAdd(false)}>
{t('app_passwords.cancel')}
@@ -9,6 +9,7 @@ import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
@@ -138,7 +139,7 @@ export function AddressBookManagementSettings() {
const handleDelete = async (book: AddressBook) => {
if (!client) return;
const ok = await confirm({
title: t("delete"),
title: t("delete_title"),
message: t("confirm_delete", { name: book.name }),
confirmText: t("delete"),
variant: "destructive",
@@ -207,10 +208,10 @@ export function AddressBookManagementSettings() {
>
<Book className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{book.name}</span>
<span className="text-sm font-medium truncate block" title={book.name} dir="auto">{book.name}</span>
</div>
{book.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
<span className="flex-shrink-0 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t("default")}
</span>
)}
@@ -227,49 +228,31 @@ export function AddressBookManagementSettings() {
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
onClick={() => setEditingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("rename")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<IconButton label={t("rename")} onClick={() => setEditingId(book.id)} className="text-muted-foreground">
<Pencil />
</IconButton>
)}
{canSetDefault && (
<button
type="button"
onClick={() => handleSetDefault(book)}
disabled={isLoading}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title={t("set_default")}
>
<Star className="w-3.5 h-3.5" />
</button>
<IconButton label={t("set_default")} onClick={() => handleSetDefault(book)} disabled={isLoading} className="text-muted-foreground">
<Star />
</IconButton>
)}
{!book.isShared && book.myRights?.mayShare && (
<button
type="button"
onClick={() => setSharingId(book.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("share")}
>
<Users className="w-3.5 h-3.5" />
</button>
<IconButton label={t("share")} onClick={() => setSharingId(book.id)} className="text-muted-foreground">
<Users />
</IconButton>
)}
{canDelete && (
<button
type="button"
<IconButton
label={t("delete")}
onClick={() => void handleDelete(book)}
disabled={isLoading}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
title={t("delete")}
aria-label={t("delete")}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
)}
</div>
</div>
@@ -9,7 +9,10 @@ import { useAuthStore } from '@/stores/auth-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { toast } from '@/stores/toast-store';
import { SettingsSection } from './settings-section';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical } from '@/components/icons';
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users, Bell, ArrowUp, ArrowDown, GripVertical, MoreVertical } from '@/components/icons';
import { IconButton } from '@/components/ui/icon-button';
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
import { useContextMenu } from '@/hooks/use-context-menu';
import { AlertListEditor } from '@/components/calendar/alert-list-editor';
import { alertsToRows, rowsToAlerts, type AlertRow } from '@/lib/calendar-alerts';
import { ShareCollectionDialog } from './share-collection-dialog';
@@ -287,6 +290,7 @@ export function CalendarManagementSettings() {
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
const colorPickerRef = useRef<HTMLDivElement>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const listedCalendars = calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
@@ -560,8 +564,64 @@ export function CalendarManagementSettings() {
}
};
// One action list per calendar (R6): the row's icons on wider screens, the
// ⋮ and right-click menu everywhere.
const calendarActions = (cal: Calendar) => {
const actions: Array<{ id: string; label: string; icon: typeof Pencil; run: () => void; destructive?: boolean }> = [
{ id: 'edit', label: t('edit'), icon: Pencil, run: () => setEditingId(cal.id) },
];
// The owner's defaults; a sharee can't write the calendar.
if (!cal.isShared) {
actions.push({ id: 'alerts', label: t('default_alerts'), icon: Bell, run: () => setAlertsEditingId(cal.id) });
}
if (cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id)) {
actions.push({ id: 'share', label: t('share'), icon: Users, run: () => setSharingId(cal.id) });
}
actions.push({ id: 'clear', label: t('clear_events'), icon: Eraser, run: () => setClearingId(cal.id) });
if (!cal.isDefault) {
actions.push({ id: 'delete', label: t('delete'), icon: Trash2, run: () => setDeletingId(cal.id), destructive: true });
}
return actions;
};
const renderRowMenu = () => {
const cal = contextMenu.data;
if (!cal) return null;
const run = (fn: () => void) => () => { closeContextMenu(); fn(); };
const siblings = cal.isShared ? [] : orderableCalendars.filter(c => calendarAccountKey(c) === calendarAccountKey(cal));
const orderIndex = siblings.indexOf(cal);
const previousId = siblings[orderIndex - 1]?.id;
const nextId = siblings[orderIndex + 1]?.id;
const buildUrl = buildCalDavUrl(cal.id);
const actions = calendarActions(cal);
const plain = actions.filter((a) => !a.destructive);
const destructive = actions.filter((a) => a.destructive);
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
{plain.map((a) => (
<ContextMenuItem key={a.id} icon={a.icon} label={a.label} onClick={run(a.run)} />
))}
{buildUrl && (
<ContextMenuItem icon={Copy} label={t('copy_url')} onClick={run(() => handleCopyUrl(buildUrl))} />
)}
{siblings.length > 1 && (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={ArrowUp} label={t('move_up')} disabled={!previousId} onClick={run(() => void handleMove(cal.id, previousId))} />
<ContextMenuItem icon={ArrowDown} label={t('move_down')} disabled={!nextId} onClick={run(() => void handleMove(cal.id, nextId))} />
</>
)}
{destructive.length > 0 && <ContextMenuSeparator />}
{destructive.map((a) => (
<ContextMenuItem key={a.id} icon={a.icon} label={a.label} destructive onClick={run(a.run)} />
))}
</ContextMenu>
);
};
return (
<SettingsSection id="calendars" title={t('title')} description={t('description')}>
{renderRowMenu()}
<div className="space-y-2">
{listedCalendars.map((cal) => {
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
@@ -611,8 +671,9 @@ export function CalendarManagementSettings() {
onDragOver={isOrderable && draggingId ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } : undefined}
onDrop={isOrderable && draggingId ? (e) => { e.preventDefault(); void handleMove(draggingId, cal.id); setDraggingId(null); } : undefined}
onDragEnd={() => setDraggingId(null)}
onContextMenu={(e) => openContextMenu(e, cal)}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group",
"flex items-center gap-2 sm:gap-3 py-2.5 px-3 rounded-md border border-border bg-background group",
draggingId === cal.id && "opacity-50",
)}
>
@@ -628,6 +689,7 @@ export function CalendarManagementSettings() {
className="w-5 h-5 rounded-full shrink-0 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ backgroundColor: color }}
title={t('change_color')}
aria-label={t('change_color')}
/>
{/* Inline color picker popover */}
@@ -648,7 +710,7 @@ export function CalendarManagementSettings() {
<CalendarIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{cal.name}</span>
<span className="text-sm font-medium truncate block" title={cal.name} dir="auto">{cal.name}</span>
{(() => {
const caldavUrl = buildCalDavUrl(cal.id);
if (!caldavUrl) return null;
@@ -658,24 +720,23 @@ export function CalendarManagementSettings() {
<span className="text-xs text-muted-foreground truncate" title={caldavUrl}>
{caldavUrl}
</span>
<button
type="button"
<IconButton
label={t('copy_url')}
onClick={(e) => {
e.stopPropagation();
handleCopyUrl(caldavUrl);
}}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
title={t('copy_url')}
className="-my-1.5 hidden sm:inline-flex flex-shrink-0 text-muted-foreground"
>
<Copy className="w-3 h-3" />
</button>
<Copy className="w-3.5 h-3.5" />
</IconButton>
</div>
);
})()}
</div>
{cal.isDefault && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
<span className="flex-shrink-0 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{t('default')}
</span>
)}
@@ -689,7 +750,7 @@ export function CalendarManagementSettings() {
: t('shared_with_count', { count: shareCount });
return (
<span
className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
className="flex flex-shrink-0 items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full"
title={label}
data-testid="calendar-shared-indicator"
>
@@ -700,81 +761,44 @@ export function CalendarManagementSettings() {
);
})()}
{/* Phone: seven icons next to the name left it a letter or two
and pushed the Default badge over the copy button, so the
row actions sit behind a ⋮ there, as for folders. */}
<IconButton
label={t('row_actions', { name: cal.name })}
className="sm:hidden flex-shrink-0 text-muted-foreground"
onClick={(e) => openContextMenuFrom(e.currentTarget, cal, 'end')}
>
<MoreVertical />
</IconButton>
{/* Shown for touch too: there is no hover, and no HTML drag and drop. */}
{isOrderable && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={() => handleMove(cal.id, previousId)}
disabled={!previousId}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
title={t('move_up')}
aria-label={t('move_up')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleMove(cal.id, nextId)}
disabled={!nextId}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
title={t('move_down')}
aria-label={t('move_down')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
<div className="hidden sm:flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<IconButton label={t('move_up')} onClick={() => handleMove(cal.id, previousId)} disabled={!previousId} className="text-muted-foreground">
<ArrowUp />
</IconButton>
<IconButton label={t('move_down')} onClick={() => handleMove(cal.id, nextId)} disabled={!nextId} className="text-muted-foreground">
<ArrowDown />
</IconButton>
</div>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
{/* The owner's defaults; a sharee can't write the calendar. */}
{!cal.isShared && (
<button
type="button"
onClick={() => setAlertsEditingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('default_alerts')}
aria-label={t('default_alerts')}
<div className="hidden sm:flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
{calendarActions(cal).map((action) => (
<IconButton
key={action.id}
label={action.label}
onClick={action.run}
className={cn(
"text-muted-foreground",
action.destructive && "hover:text-destructive hover:bg-destructive/10",
action.id === 'clear' && "hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400",
)}
>
<Bell className="w-3.5 h-3.5" />
</button>
)}
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
<button
type="button"
onClick={() => setSharingId(cal.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t('share')}
>
<Users className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => setClearingId(cal.id)}
className="p-1.5 rounded-md hover:bg-amber-500/10 text-muted-foreground hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
title={t('clear_events')}
>
<Eraser className="w-3.5 h-3.5" />
</button>
{!cal.isDefault && (
<button
type="button"
onClick={() => setDeletingId(cal.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<action.icon />
</IconButton>
))}
</div>
</div>
);
@@ -855,32 +879,25 @@ export function CalendarManagementSettings() {
</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingSubscription(sub)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={tSub('edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<IconButton label={tSub('edit')} onClick={() => setEditingSubscription(sub)} className="text-muted-foreground">
<Pencil />
</IconButton>
<IconButton
label={tSub('refresh')}
onClick={() => handleRefreshSubscription(sub.id)}
disabled={refreshingSubId === sub.id}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
title={tSub('refresh')}
className="text-muted-foreground"
>
<RefreshCw className={cn("w-3.5 h-3.5", refreshingSubId === sub.id && "animate-spin")} />
</button>
<button
type="button"
<RefreshCw className={cn("w-4 h-4", refreshingSubId === sub.id && "animate-spin")} />
</IconButton>
<IconButton
label={tSub('unsubscribe')}
onClick={() => setDeletingSubId(sub.id)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={tSub('unsubscribe')}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
</div>
</div>
);
+19 -20
View File
@@ -16,6 +16,8 @@ import { useEmailStore } from "@/stores/email-store";
import { useFilterStore } from "@/stores/filter-store";
import { SettingsSection, SettingItem, ToggleSwitch, Select, AdvancedOnly } from './settings-section';
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2, Search } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { cn } from "@/lib/utils";
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from "@/lib/keyword-discovery";
@@ -93,6 +95,7 @@ function KeywordRow({
}) {
const t = useTranslations("settings.keywords");
const hasChildren = hasChildKeywords(keyword.id, keywords);
const tagLabel = keyword.label || keyword.id;
// Measured with the prefix attached, since that is what occupies the column.
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
.map((rendering) => KEYWORD_PREFIX + rendering);
@@ -134,24 +137,19 @@ function KeywordRow({
ariaLabel={t("visibility_field")}
className="text-xs py-1"
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={onEdit}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={t("edit")}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<IconButton label={t("edit_tag", { name: tagLabel })} title={t("edit")} onClick={onEdit} className="text-muted-foreground">
<Pencil />
</IconButton>
<IconButton
label={t("delete_tag", { name: tagLabel })}
title={hasChildren ? t("has_children_delete") : t("delete")}
onClick={onDelete}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
</div>
</div>
);
@@ -727,17 +725,18 @@ export function KeywordSettings() {
/>
) : (
<div className="flex items-center gap-2 pt-1">
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={() => {
setIsAdding(true);
setEditingId(null);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-dashed border-border hover:border-primary hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Plus className="w-3.5 h-3.5" />
<Plus />
{t("add_keyword")}
</button>
</Button>
</div>
)}
</div>
+23 -5
View File
@@ -27,6 +27,7 @@ import {
X,
type AppIcon,
} from '@/components/icons';
import { useRegisterCommands } from '@/lib/command-registry';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
@@ -262,6 +263,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const tPalette = useTranslations('command_palette');
useDocumentTitle(tSidebar('settings'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -656,10 +658,6 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
}
}, [activeTab]);
if (!isAuthenticated) {
return null;
}
const supportsVacation = client?.supportsVacationResponse() ?? false;
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
@@ -786,6 +784,26 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId, advanced: sub.advanced });
};
// Ctrl/⌘+K in Settings: one "Go to <page>" per page shown in the list,
// found by the same words as the settings search ("out of office", "app
// password", "add rule").
useRegisterCommands(
tSidebar('settings'),
visibleTabs.map((tab) => ({
id: `go-${tab.id}`,
label: tPalette('go_to', { name: tab.label }),
icon: tab.icon,
keywords: [tabSearchHaystacks[tab.id as Tab] ?? ''],
run: () => handleTabSelect(tab.id),
when: () => tab.id !== effectiveActiveTab,
})),
isAuthenticated,
);
if (!isAuthenticated) {
return null;
}
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
const renderTabContent = () => (
@@ -909,7 +927,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<PhoneHeaderActions />
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto" data-tour="settings-tabs">
<div className="px-4 pt-3 pb-1">
<div className="relative">
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
@@ -0,0 +1,111 @@
import { act, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Which steps a run of the tour counts, and where it takes the user.
const env = vi.hoisted(() => ({
phone: true,
pathname: '/mail/folder/inbox',
push: vi.fn(),
command: vi.fn(),
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: env.push }),
usePathname: () => env.pathname,
}));
vi.mock('@/hooks/use-media-query', () => ({
useMediaQuery: () => env.phone,
useIsMobile: () => env.phone,
}));
vi.mock('@/lib/app-commands', () => ({ requestAppCommand: env.command }));
vi.mock('../tour-overlay', () => ({ TourOverlay: () => null }));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: (sel: (s: unknown) => unknown) => sel({ isDemoMode: true, client: { supportsContacts: () => true } }),
}));
vi.mock('@/stores/calendar-store', () => ({
useCalendarStore: (sel: (s: unknown) => unknown) => sel({ supportsCalendar: true }),
}));
vi.mock('@/stores/webdav-store', () => ({
useWebDAVStore: (sel: (s: unknown) => unknown) => sel({ supportsWebDAV: true }),
}));
vi.mock('@/stores/policy-store', () => ({
usePolicyStore: (sel: (s: unknown) => unknown) => sel({ isFeatureEnabled: () => true }),
}));
const updateSetting = vi.fn();
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (sel: (s: unknown) => unknown) => sel({ tourCompleted: false, showOnboardingOnNewDevices: false, updateSetting }),
}));
const { TourProvider, useTour } = await import('../tour-provider');
const { getTourSteps } = await import('../tour-steps');
let api: ReturnType<typeof useTour>;
function Probe() {
api = useTour();
const step = api.steps[api.currentStep];
return <p data-testid="probe">{api.isActive ? `${api.currentStep + 1}/${api.totalSteps}/${step?.id}` : 'off'}</p>;
}
const probe = () => screen.getByTestId('probe').textContent;
beforeEach(() => {
env.phone = true;
env.pathname = '/mail/folder/inbox';
env.push.mockClear();
env.command.mockClear();
localStorage.clear();
});
function start() {
render(<TourProvider><Probe /></TourProvider>);
act(() => api.startTour());
}
describe('tour run', () => {
it('counts only the steps a phone shows, starting at step 1', () => {
start();
const shown = getTourSteps({ isDemoMode: true, supportsCalendar: true, supportsWebDAV: true, supportsContacts: true, touch: true, phone: true });
expect(probe()).toBe(`1/${shown.length}/compose`);
});
it('drops a step that has nothing to point at, so the count stays whole', () => {
start();
const total = api.totalSteps;
act(() => api.nextStep());
expect(probe()).toBe(`2/${total}/search`);
act(() => api.skipStep(1));
expect(probe()).toBe(`2/${total - 1}/email-list`);
act(() => api.skipStep(-1));
expect(probe()).toBe(`1/${total - 2}/compose`);
});
it('closes the phone reader for Mail steps but not for the reader step', () => {
start();
expect(env.command).toHaveBeenLastCalledWith('show-list');
while (api.steps[api.currentStep].id !== 'email-viewer') act(() => api.nextStep());
env.command.mockClear();
env.pathname = '/mail/message/m1';
act(() => api.nextStep());
// The next Mail step (Calendar in the tab bar) needs the list back.
expect(api.steps[api.currentStep].id).toBe('nav-calendar');
expect(env.command).toHaveBeenCalledWith('show-list');
env.command.mockClear();
act(() => api.prevStep());
expect(env.command).not.toHaveBeenCalled();
});
it('takes the user to the app of the step', () => {
start();
while (api.steps[api.currentStep].id !== 'calendar-view') act(() => api.nextStep());
expect(env.push).toHaveBeenLastCalledWith('/calendar');
// File storage is shown over Files.
while (api.steps[api.currentStep].id !== 'nav-files') act(() => api.nextStep());
expect(env.push).toHaveBeenLastCalledWith('/files');
});
it('leaves a desktop reader open', () => {
env.phone = false;
start();
expect(env.command).not.toHaveBeenCalled();
});
});
+83 -8
View File
@@ -4,29 +4,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The tour on a phone, where the rail's anchors are not rendered, and its
// keyboard handling.
const nav = vi.hoisted(() => ({ pathname: '/' }));
vi.mock('@/i18n/navigation', () => ({ usePathname: () => nav.pathname }));
const tour = vi.hoisted(() => ({
currentStep: 0,
nextStep: vi.fn(),
prevStep: vi.fn(),
skipStep: vi.fn(),
stopTour: vi.fn(),
// The same objects on every render, as the provider's steps are.
steps: [
{ id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' },
{ id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' },
{ id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' },
{ id: 'files', target: '[data-tour="four"]', titleKey: 'four', descriptionKey: 'four_desc', placement: 'right', page: '/files' },
],
}));
vi.mock('../tour-provider', () => ({
useTour: () => ({
currentStep: tour.currentStep,
totalSteps: 3,
steps: [
{ id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' },
{ id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' },
{ id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' },
],
steps: tour.steps,
nextStep: tour.nextStep,
prevStep: tour.prevStep,
skipStep: tour.skipStep,
stopTour: tour.stopTour,
}),
}));
const { TourOverlay, findTourTarget } = await import('../tour-overlay');
const { TourOverlay, findTourTarget, computeTooltipPosition } = await import('../tour-overlay');
const { getTourSteps } = await import('../tour-steps');
/** jsdom lays nothing out: give the elements marked `data-size` a box. */
@@ -34,8 +42,10 @@ const realRect = HTMLElement.prototype.getBoundingClientRect;
beforeEach(() => {
vi.useFakeTimers();
tour.currentStep = 0;
nav.pathname = '/';
tour.nextStep.mockClear();
tour.prevStep.mockClear();
tour.skipStep.mockClear();
tour.stopTour.mockClear();
HTMLElement.prototype.getBoundingClientRect = function () {
const sized = this.hasAttribute('data-size');
@@ -48,6 +58,8 @@ afterEach(() => {
HTMLElement.prototype.getBoundingClientRect = realRect;
hosts.splice(0).forEach((h) => h.remove());
document.documentElement.removeAttribute('dir');
// jsdom has no elementsFromPoint; tests that need it add one.
delete (document as { elementsFromPoint?: unknown }).elementsFromPoint;
});
const hosts: HTMLElement[] = [];
@@ -69,11 +81,47 @@ describe('tour targets', () => {
expect(findTourTarget('[data-tour="one"]')).toBeNull();
});
it('skips a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => {
it('ignores a copy covered by something else, like the tab bar under an open message', () => {
anchors('<a data-tour="one" id="tabbar" data-size></a><div id="reader-bar" data-size></div>');
const reader = document.getElementById('reader-bar')!;
(document as { elementsFromPoint?: unknown }).elementsFromPoint = () => [reader, document.body];
expect(findTourTarget('[data-tour="one"]')).toBeNull();
// The tour's own layers on top don't count.
const layer = document.createElement('div');
layer.setAttribute('data-tour-layer', '');
const tabbar = document.getElementById('tabbar')!;
(document as { elementsFromPoint?: unknown }).elementsFromPoint = () => [layer, tabbar, document.body];
expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar');
});
it('drops a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => {
anchors('<a data-tour="one"></a>');
render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(1100); });
expect(tour.nextStep).toHaveBeenCalledTimes(1);
expect(tour.skipStep).toHaveBeenCalledWith(1);
expect(tour.nextStep).not.toHaveBeenCalled();
});
it("waits for the step's app before pointing at an anchor the app being left also has", () => {
anchors('<a data-tour="four" data-size></a>');
tour.currentStep = 3;
nav.pathname = '/settings';
const { rerender } = render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(6000); });
expect(tour.skipStep).not.toHaveBeenCalled();
expect(document.querySelector('[role="dialog"]')).toHaveClass('invisible');
nav.pathname = '/files';
rerender(<TourOverlay />);
act(() => { vi.advanceTimersByTime(400); });
expect(screen.getByRole('dialog', { name: 'four' })).not.toHaveClass('invisible');
});
it('has no card that catches taps while it looks for the target', () => {
render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(300); });
const card = document.querySelector('[role="dialog"]') as HTMLElement;
expect(card).toHaveClass('invisible');
expect(card.style.pointerEvents).toBe('none');
});
it('shows a step whose target is on screen', () => {
@@ -92,6 +140,33 @@ describe('tour targets', () => {
expect(ids({})).toContain('nav-contacts');
expect(ids({ supportsContacts: false })).not.toContain('nav-contacts');
});
it('puts the card above a tab at the end of the phone tab bar instead of over it', () => {
const w = window.innerWidth;
const hgt = window.innerHeight;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 });
try {
const tab = { left: 234, top: 792, width: 78, height: 52 };
const pos = computeTooltipPosition(tab, 'right', { width: 360, height: 196 });
expect(pos.top + 196).toBeLessThanOrEqual(tab.top);
expect(pos.left + 360).toBeLessThanOrEqual(390 - 8);
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: w });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: hgt });
}
});
it('leaves out on a phone the steps that point into the rail or the folder drawer', () => {
const ids = (phone: boolean) =>
getTourSteps({ isDemoMode: true, supportsCalendar: true, supportsWebDAV: true, touch: phone, phone }).map((s) => s.id);
for (const id of ['sidebar', 'keywords', 'demo-banner', 'quota']) {
expect(ids(false), id).toContain(id);
expect(ids(true), id).not.toContain(id);
}
expect(ids(true)[0]).toBe('compose');
expect(ids(true)).toContain('nav-files');
});
});
describe('tour keyboard', () => {
+37 -31
View File
@@ -9,6 +9,10 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { Button } from "@/components/ui/button";
import { isEditableEventTarget } from "@/lib/keyboard";
import { isDocumentRTL } from "@/i18n/direction";
import { findTourTarget, isTourStepOnPage } from "./tour-steps";
import { usePathname } from "@/i18n/navigation";
export { findTourTarget };
interface Rect {
top: number;
@@ -21,23 +25,6 @@ const PADDING = 8;
const TOOLTIP_GAP = 12;
const TOOLTIP_MAX_W = 360;
/**
* The first element for the selector that is on screen. The same anchor can
* exist twice (the rail and the phone's tab bar); one with no size is not
* rendered in this layout, and one in a closed drawer (inert, or pushed off
* the side of the screen) can't be seen: neither has anything to point at.
*/
export function findTourTarget(selector: string): Element | null {
for (const el of document.querySelectorAll(selector)) {
if (el.closest("[inert]")) continue;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (r.right <= 0 || r.left >= window.innerWidth) continue;
return el;
}
return null;
}
function getTargetRect(selector: string): Rect | null {
const el = findTourTarget(selector);
if (!el) return null;
@@ -49,8 +36,10 @@ function getTargetRect(selector: string): Rect | null {
const HIDDEN_TARGET_ATTEMPTS = 10;
/** Polls before a step whose target never appeared (another page loading) is skipped. */
const MISSING_TARGET_ATTEMPTS = 50;
/** Polls while the step's app is still loading (a first visit compiles it in development). */
const PAGE_LOAD_ATTEMPTS = 300;
function computeTooltipPosition(
export function computeTooltipPosition(
target: Rect,
placement: "top" | "bottom" | "left" | "right",
tooltipSize: { width: number; height: number }
@@ -60,21 +49,25 @@ function computeTooltipPosition(
const tw = Math.max(tooltipSize.width, 200); // minimum fallback width
const th = Math.max(tooltipSize.height, 100); // minimum fallback height
// Along the side it sits on, the card slides to stay on screen: above a
// tab at the end of the phone's tab bar it can't be centred on the tab.
const alongX = (left: number) => Math.max(8, Math.min(left, vw - tw - 8));
const alongY = (top: number) => Math.max(8, Math.min(top, vh - th - 8));
const positions = {
bottom: {
top: target.top + target.height + PADDING + TOOLTIP_GAP,
left: target.left + target.width / 2 - tw / 2,
left: alongX(target.left + target.width / 2 - tw / 2),
},
top: {
top: target.top - PADDING - TOOLTIP_GAP - th,
left: target.left + target.width / 2 - tw / 2,
left: alongX(target.left + target.width / 2 - tw / 2),
},
right: {
top: target.top + target.height / 2 - th / 2,
top: alongY(target.top + target.height / 2 - th / 2),
left: target.left + target.width + PADDING + TOOLTIP_GAP,
},
left: {
top: target.top + target.height / 2 - th / 2,
top: alongY(target.top + target.height / 2 - th / 2),
left: target.left - PADDING - TOOLTIP_GAP - tw,
},
};
@@ -100,8 +93,13 @@ function computeTooltipPosition(
export function TourOverlay() {
const t = useTranslations();
const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour();
const { currentStep, totalSteps, steps, nextStep, prevStep, skipStep, stopTour } = useTour();
const step = steps[currentStep];
// The same anchor can be in the app being left (the tab bar in Settings
// while Files loads): look for the target only once the step's app is up.
const pathname = usePathname();
const onPageRef = useRef(true);
onPageRef.current = !step || isTourStepOnPage(step, pathname);
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
@@ -112,14 +110,12 @@ export function TourOverlay() {
// Use refs for callbacks to avoid stale closures in timers/intervals
const updatePositionRef = useRef<() => void>(() => {});
const nextStepRef = useRef<() => void>(() => {});
const prevStepRef = useRef<() => void>(() => {});
const skipStepRef = useRef<(direction: 1 | -1) => void>(() => {});
// A step with nothing to point at is skipped in the direction the user went.
const directionRef = useRef<1 | -1>(1);
const goNext = useCallback(() => { directionRef.current = 1; nextStep(); }, [nextStep]);
const goPrev = useCallback(() => { directionRef.current = -1; prevStep(); }, [prevStep]);
nextStepRef.current = nextStep;
prevStepRef.current = prevStep;
skipStepRef.current = skipStep;
const focusTrapRef = useFocusTrap({
isActive: visible,
@@ -172,6 +168,7 @@ export function TourOverlay() {
const tryFind = () => {
if (cancelled) return true;
if (!onPageRef.current) return false;
const el = findTourTarget(step.target);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
@@ -195,7 +192,9 @@ export function TourOverlay() {
// Poll for the element to appear (another page loading). One that is in
// the DOM but not rendered in this layout (the rail on a phone) is given
// up on sooner.
let loading = 0;
const interval = setInterval(() => {
if (!onPageRef.current && ++loading < PAGE_LOAD_ATTEMPTS) return;
attempts++;
if (tryFind()) {
clearInterval(interval);
@@ -205,8 +204,8 @@ export function TourOverlay() {
if (attempts >= limit) {
clearInterval(interval);
if (cancelled) return;
if (directionRef.current === -1 && currentStep > 0) prevStepRef.current();
else nextStepRef.current();
// Dropped from this run, so the counter never counts it.
skipStepRef.current(directionRef.current);
}
}, 100);
@@ -301,6 +300,7 @@ export function TourOverlay() {
<>
{/* SVG overlay with cutout */}
<svg
data-tour-layer=""
className="fixed inset-0 z-[9998]"
width="100%"
height="100%"
@@ -335,6 +335,7 @@ export function TourOverlay() {
{/* Click-through cutout zone for interactive steps */}
{isInteractive && cutout && (
<div
data-tour-layer=""
className="fixed z-[9998]"
style={{
top: cutout.y,
@@ -350,6 +351,7 @@ export function TourOverlay() {
{/* Non-interactive overlay click blocker around cutout */}
{!isInteractive && cutout && (
<div
data-tour-layer=""
className="fixed z-[9998]"
style={{
top: cutout.y,
@@ -367,19 +369,23 @@ export function TourOverlay() {
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
(focusTrapRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
data-tour-layer=""
role="dialog"
aria-modal="true"
aria-label={t(step.titleKey)}
aria-hidden={!visible || undefined}
className={cn(
"fixed z-[9999] transition-all",
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
// While the next target is looked for, the card is not there at
// all: an invisible card would still catch taps.
visible ? "opacity-100 translate-y-0" : "invisible opacity-0 translate-y-2"
)}
style={{
top: tooltipPos?.top ?? -9999,
left: tooltipPos?.left ?? -9999,
maxWidth: TOOLTIP_MAX_W,
transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease",
pointerEvents: "auto",
pointerEvents: visible ? "auto" : "none",
}}
onClick={(e) => e.stopPropagation()}
>
+61 -45
View File
@@ -8,8 +8,9 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { getTourSteps, type TourStep } from "./tour-steps";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getTourSteps, isTourStepOnPage, type TourStep } from "./tour-steps";
import { useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { requestAppCommand } from "@/lib/app-commands";
import { TourOverlay } from "./tour-overlay";
const TOUR_COMPLETED_KEY = "tour_completed";
@@ -24,6 +25,12 @@ interface TourContextValue {
stopTour: () => void;
nextStep: () => void;
prevStep: () => void;
/**
* Drops the current step, which has nothing to point at, from this run of
* the tour, so the counter only ever counts steps that show. `direction`
* is the way the user was going.
*/
skipStep: (direction: 1 | -1) => void;
hasCompletedTour: boolean;
resetTourCompletion: () => void;
}
@@ -36,6 +43,12 @@ export function useTour() {
return ctx;
}
function rememberStep(step: TourStep | undefined) {
try {
if (step) localStorage.setItem(TOUR_CURRENT_STEP_KEY, step.id);
} catch { /* */ }
}
export function TourProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
@@ -54,14 +67,32 @@ export function TourProvider({ children }: { children: ReactNode }) {
const client = useAuthStore((s) => s.client);
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const touch = useMediaQuery("(pointer: coarse)");
const phone = useIsMobile();
// Steps found to have nothing to point at during this run of the tour.
const [skipped, setSkipped] = useState<string[]>([]);
const steps = getTourSteps({
const allSteps = getTourSteps({
isDemoMode,
supportsCalendar: supportsCalendar && calendarEnabled,
supportsWebDAV: supportsWebDAV !== false,
supportsContacts: (client?.supportsContacts?.() ?? false) && contactsEnabled,
touch,
phone,
});
const steps = skipped.length ? allSteps.filter((s) => !skipped.includes(s.id)) : allSteps;
const step = isActive ? steps[currentStep] : undefined;
// Take the user to the app the step is about. On a phone the open message
// covers the list and the tab bar, so every Mail step but the reader's own
// closes it first.
useEffect(() => {
if (!step) return;
if (!isTourStepOnPage(step, pathname)) {
router.push(step.page ?? "/");
} else if (phone && !step.page && step.view !== "reader") {
requestAppCommand("show-list");
}
}, [step, pathname, phone, router]);
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
@@ -88,29 +119,20 @@ export function TourProvider({ children }: { children: ReactNode }) {
setHasCompletedTour(true);
}, [tourCompleted, showOnboardingOnNewDevices]);
// A fresh run gives every step its chance again; the effect above takes the
// user to the first step's app.
const startTour = useCallback(() => {
let resumeStep = 0;
try {
const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed;
}
const index = stored ? allSteps.findIndex((s) => s.id === stored) : -1;
if (index >= 0) resumeStep = index;
} catch { /* */ }
// If the resume step is beyond the current steps, start from 0
if (resumeStep >= steps.length) resumeStep = 0;
// Most steps live on the mailbox - navigate there (or to the step's specific page)
// so we don't start the tour on a page where the targets don't exist.
const targetPage = steps[resumeStep]?.page ?? "/";
if (pathname !== targetPage) {
router.push(targetPage);
}
setSkipped([]);
setCurrentStep(resumeStep);
setIsActive(true);
}, [steps, pathname, router]);
}, [allSteps]);
const stopTour = useCallback(() => {
setIsActive(false);
@@ -134,38 +156,31 @@ export function TourProvider({ children }: { children: ReactNode }) {
completeTour();
return;
}
const next = currentStep + 1;
const nextStepDef = steps[next];
const currentStepDef = steps[currentStep];
setCurrentStep(next);
try {
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
} catch { /* */ }
// Navigate when the next step is on a different page (treat "no page" as the mailbox)
const nextPage = nextStepDef?.page ?? "/";
const currentPage = currentStepDef?.page ?? "/";
if (nextPage !== currentPage) {
router.push(nextPage);
}
}, [currentStep, steps, completeTour, router]);
setCurrentStep(currentStep + 1);
rememberStep(steps[currentStep + 1]);
}, [currentStep, steps, completeTour]);
const prevStep = useCallback(() => {
if (currentStep <= 0) return;
const prev = currentStep - 1;
const prevStepDef = steps[prev];
setCurrentStep(prev);
try {
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev));
} catch { /* */ }
setCurrentStep(currentStep - 1);
rememberStep(steps[currentStep - 1]);
}, [currentStep, steps]);
if (prevStepDef?.page) {
router.push(prevStepDef.page);
} else if (steps[currentStep]?.page) {
// Going back from a page-specific step to a non-page step => go to mail
router.push("/");
const skipStep = useCallback((direction: 1 | -1) => {
const current = steps[currentStep];
if (!current) return;
const rest = steps.filter((s) => s !== current);
// Going on, the next step moves up into this index; going back, the
// previous one is a place lower (at the start, the next one shows).
const index = direction === -1 && currentStep > 0 ? currentStep - 1 : currentStep;
if (index >= rest.length) {
completeTour();
return;
}
}, [currentStep, steps, router]);
setSkipped((ids) => [...ids, current.id]);
setCurrentStep(index);
rememberStep(rest[index]);
}, [currentStep, steps, completeTour]);
const resetTourCompletion = useCallback(() => {
setHasCompletedTour(false);
@@ -185,6 +200,7 @@ export function TourProvider({ children }: { children: ReactNode }) {
stopTour,
nextStep,
prevStep,
skipStep,
hasCompletedTour,
resetTourCompletion,
};
+65 -11
View File
@@ -6,11 +6,60 @@ export interface TourStep {
placement: "top" | "bottom" | "left" | "right";
interactive?: boolean;
spotlight?: "rect" | "circle";
/** The app the step is in; none means Mail. */
page?: string;
/**
* "reader": the step is in Mail's reading pane, which on a phone covers the
* list and the tab bar. Every other Mail step closes the reader there.
*/
view?: "reader";
/** false: the target only exists in the rail or the folder drawer, so a phone never shows it. */
phone?: false;
demoOnly?: boolean;
beforeAction?: () => void;
}
/**
* The first element for the selector that the user can see: not in a closed
* drawer (inert), with a size, on screen sideways and not covered by
* something else (the open message over the phone's tab bar). The same anchor
* can exist twice (the rail and the phone's tab bar).
*/
export function findTourTarget(selector: string): Element | null {
for (const el of document.querySelectorAll(selector)) {
if (el.closest("[inert]")) continue;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (r.right <= 0 || r.left >= window.innerWidth) continue;
if (isCovered(el, r)) continue;
return el;
}
return null;
}
/** Whether the middle of an on-screen element is under another element (the tour's own layers aside). */
function isCovered(el: Element, r: DOMRect): boolean {
if (typeof document.elementsFromPoint !== "function") return false;
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
// Below or above the fold: scrolled into view before it is shown.
if (y < 0 || y > window.innerHeight || x < 0 || x > window.innerWidth) return false;
const top = document.elementsFromPoint(x, y).find((hit) => !hit.closest("[data-tour-layer]"));
return !!top && top !== el && !el.contains(top) && !top.contains(el);
}
/** Whether the user is in the app the step is about. */
export function isTourStepOnPage(step: Pick<TourStep, "page">, pathname: string): boolean {
const page = step.page && step.page !== "/" ? step.page : null;
if (!page) return pathname === "/" || pathname === "" || pathname === "/mail" || pathname.startsWith("/mail/");
return pathname === page || pathname.startsWith(page + "/");
}
/** Clicks the visible copy of a tour anchor (the phone's compose button, not the closed drawer's). */
function clickTourTarget(selector: string): void {
(findTourTarget(selector) as HTMLElement | null)?.click();
}
export const BASE_TOUR_STEPS: TourStep[] = [
{
id: "sidebar",
@@ -18,6 +67,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [
titleKey: "tour.sidebar_title",
descriptionKey: "tour.sidebar_desc",
placement: "right",
phone: false,
},
{
id: "compose",
@@ -48,6 +98,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [
titleKey: "tour.email_viewer_title",
descriptionKey: "tour.email_viewer_desc",
placement: "left",
view: "reader",
beforeAction: () => {
// Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer
const emailList = document.querySelector('[data-tour="email-list"]');
@@ -72,6 +123,7 @@ export const BASE_TOUR_STEPS: TourStep[] = [
titleKey: "tour.keywords_title",
descriptionKey: "tour.keywords_desc",
placement: "right",
phone: false,
},
{
id: "nav-calendar",
@@ -111,12 +163,9 @@ export const DEMO_TOUR_STEPS: TourStep[] = [
titleKey: "tour.compose_open_title",
descriptionKey: "tour.compose_open_desc",
placement: "left",
view: "reader",
demoOnly: true,
beforeAction: () => {
// Click the compose button to open the composer
const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null;
if (btn) btn.click();
},
beforeAction: () => clickTourTarget('[data-tour="compose-button"]'),
},
{
id: "calendar-view",
@@ -146,11 +195,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [
page: "/calendar",
interactive: true,
demoOnly: true,
beforeAction: () => {
// Click the create event button to open the modal
const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null;
if (btn) btn.click();
},
beforeAction: () => clickTourTarget('[data-tour="create-event-button"]'),
},
{
id: "contacts-list",
@@ -176,6 +221,8 @@ export const DEMO_TOUR_STEPS: TourStep[] = [
titleKey: "tour.files_title",
descriptionKey: "tour.files_desc",
placement: "right",
// Shown over the Files app, pointing at its place in the rail or tab bar.
page: "/files",
demoOnly: true,
},
{
@@ -184,7 +231,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [
titleKey: "tour.demo_banner_title",
descriptionKey: "tour.demo_banner_desc",
placement: "bottom",
page: "/",
phone: false,
demoOnly: true,
},
{
@@ -193,6 +240,7 @@ export const DEMO_TOUR_STEPS: TourStep[] = [
titleKey: "tour.quota_title",
descriptionKey: "tour.quota_desc",
placement: "right",
phone: false,
demoOnly: true,
},
];
@@ -205,6 +253,8 @@ export function getTourSteps(options: {
supportsContacts?: boolean;
/** A touch screen: no keyboard to use the shortcuts with. */
touch?: boolean;
/** The phone layout: no rail, the folders in a drawer. */
phone?: boolean;
}): TourStep[] {
let steps = [...BASE_TOUR_STEPS];
@@ -227,5 +277,9 @@ export function getTourSteps(options: {
steps = [...steps, ...demoSteps];
}
if (options.phone) {
steps = steps.filter((s) => s.phone !== false);
}
return steps;
}
@@ -22,4 +22,15 @@ describe('ContextMenu rows on a touch screen', () => {
expect(row.className).not.toMatch(/(^|\s)min-h-11/);
}
});
it('hides key hints under a coarse pointer only (R8)', () => {
render(
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
<ContextMenuItem label="Rename" shortcut="F2" onClick={() => {}} />
</ContextMenu>,
);
const hint = screen.getByText('F2');
expect(hint.className).toContain('pointer-coarse:hidden');
expect(hint.className).not.toMatch(/(^|\s)hidden/);
});
});
+11
View File
@@ -59,6 +59,17 @@ describe('Dialog', () => {
expect(document.activeElement).toBe(opener);
});
it('skips fields out of the tab order, like the hidden input behind a drop zone', () => {
render(
<Dialog isOpen onClose={() => {}} title="T">
<button type="button">drop zone</button>
<input type="file" tabIndex={-1} className="hidden" aria-label="file" />
<input aria-label="name" />
</Dialog>,
);
expect(document.activeElement).toBe(screen.getByLabelText('name'));
});
it('honours initialFocus', () => {
function Host() {
const target = useRef<HTMLButtonElement>(null);
@@ -41,6 +41,13 @@ describe('toast layout', () => {
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
});
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
render(<Toasts />);
const container = screen.getByRole('status');
expect(container).toHaveClass('bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))]');
expect(container).not.toHaveClass('bottom-5');
});
it('names the close button and hides it on a phone', () => {
render(<Toasts />);
act(() => { toast.success('Saved'); });
+2 -1
View File
@@ -560,8 +560,9 @@ export function ContextMenuItem({
) : (
<span className="flex-1">{label}</span>
)}
{/* Key hints mean nothing without a keyboard: hidden on touch (R8). */}
{shortcut && (
<span className="text-xs text-muted-foreground ms-auto">{shortcut}</span>
<span className="text-xs text-muted-foreground ms-auto pointer-coarse:hidden">{shortcut}</span>
)}
</button>
);
+3 -1
View File
@@ -53,8 +53,10 @@ const SIZES: Record<DialogSize, string> = {
"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 <body> at z-[60] over a dimmed
+3 -2
View File
@@ -317,8 +317,9 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
<div
className={cn(
// Newest at the bottom, at the end corner (bottom left in RTL);
// lifted over an open composer's footer.
"fixed bottom-5 end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
// lifted over a registered bottom bar (a form's Save/Cancel footer)
// and over an open composer's footer.
"fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
// Phone: full width with 12px margins, above the bottom controls
// (the tab bar, or whichever bar registered with useBottomControls).
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
@@ -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));
@@ -0,0 +1,35 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
// German dates on the event card read "Sa, Okt 31, 2026": English order
// with translated names. Each locale now gets its own order.
let locale = 'en';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => locale,
}));
import { useFormatEventDate } from '../use-format-event-date';
function formatIn(code: string, date: Date) {
locale = code;
const { result } = renderHook(() => useFormatEventDate());
return result.current(date);
}
describe('useFormatEventDate', () => {
const date = new Date(2026, 9, 31);
it('keeps the English order in English', () => {
expect(formatIn('en', date)).toBe('Sat, Oct 31, 2026');
});
it('puts the day before the month in German', () => {
expect(formatIn('de', date)).toBe('Sa., 31. Okt. 2026');
});
it('follows French too', () => {
expect(formatIn('fr', date)).toBe('sam. 31 oct. 2026');
});
});
@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useHoverPreviewGate } from '../use-hover-preview-gate';
// The hover preview opened on top of the open editor, and by itself after
// a dialog closed with the pointer resting on an event.
function move(x: number, y: number) {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: x, clientY: y }));
}
describe('useHoverPreviewGate', () => {
it('lets a preview open when nothing is open', () => {
const { result } = renderHook(() => useHoverPreviewGate(false));
expect(result.current()).toBe(true);
});
it('holds previews back while an editor is open, and after it closed until the pointer moves', () => {
const { result, rerender } = renderHook(({ open }) => useHoverPreviewGate(open), { initialProps: { open: true } });
move(100, 100);
expect(result.current()).toBe(false);
rerender({ open: false });
expect(result.current()).toBe(false);
move(101, 101);
expect(result.current()).toBe(false);
move(140, 120);
expect(result.current()).toBe(true);
});
it('holds previews back while a modal dialog is on the page, and after it closed', async () => {
const { result } = renderHook(() => useHoverPreviewGate(false));
move(10, 10);
const dialog = document.createElement('div');
dialog.setAttribute('aria-modal', 'true');
document.body.appendChild(dialog);
expect(result.current()).toBe(false);
dialog.remove();
await new Promise((r) => setTimeout(r, 0));
expect(result.current()).toBe(false);
move(60, 60);
expect(result.current()).toBe(true);
});
});
@@ -130,3 +130,38 @@ describe('useKeyboardShortcuts: n opens the next unread message', () => {
expect(KEYBOARD_SHORTCUTS.navigation).toContainEqual({ key: 'n', description: 'shortcuts.navigation.next_unread' });
});
});
// Enter on the focused "Skip to message list" link (or any button) belongs to
// that control: it must not open the selected message.
describe('useKeyboardShortcuts: Enter on a link or button', () => {
function setup() {
const onOpenEmail = vi.fn();
renderHook(() =>
useKeyboardShortcuts({ enabled: true, emails: [], selectedEmailId: 'e1', handlers: { onOpenEmail } }),
);
return onOpenEmail;
}
it.each([
['a link', () => { const a = document.createElement('a'); a.href = '#message-list'; return a; }],
['a button', () => document.createElement('button')],
])('leaves Enter on %s to it', (_name, make) => {
const onOpenEmail = setup();
const el = make();
document.body.appendChild(el);
try {
const event = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true });
el.dispatchEvent(event);
expect(onOpenEmail).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
} finally {
el.remove();
}
});
it('still opens the selected message with Enter elsewhere', () => {
const onOpenEmail = setup();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
expect(onOpenEmail).toHaveBeenCalledTimes(1);
});
});
+3 -2
View File
@@ -3,8 +3,9 @@
import { useEffect, useState } from 'react';
/**
* Bars pinned to the bottom of the screen on a phone (the tab bar, a
* viewer's action bar) register here. While any is on screen,
* Bars pinned to the bottom of the screen register here: on a phone the tab
* bar and a viewer's action bar, on any screen an editor's Save/Cancel
* footer. While any is on screen,
* `--bottom-controls-height` on <html> says how far from the bottom of the
* window the topmost of them reaches, and toasts sit above it.
*/
+5 -2
View File
@@ -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
+16
View File
@@ -200,6 +200,21 @@ export function useCalendarLocale() {
});
};
/**
* Short day label for a phone's title ("Oct 28", "28. Okt."). The weekday
* is in the day's header right under it, and with it German no longer fit.
*/
const formatDayShort = (date: Date): string => {
if (isJalali) {
const { jm, jd } = toJalali(date);
return `${jd} ${JALALI_MONTHS[jm - 1]}`;
}
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "short",
day: "numeric",
});
};
// ------------------------------------------------------------------
// Calendar-system-aware month/year getters (for navigation, etc.)
// All return values use **0-based** months to stay compatible with
@@ -244,6 +259,7 @@ export function useCalendarLocale() {
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
formatDayShort,
getMonth,
getYear,
monthLabelKeys,
+8
View File
@@ -10,6 +10,14 @@ const STATE_KEY = "__closeOnBack";
// would pop it and close the layer at once.
let pendingHistoryBack: Promise<void> | null = null;
/**
* Resolves once the Back a closed layer took to remove its history entry has
* landed. A navigation started before then would be undone by it.
*/
export function historySettled(): Promise<void> {
return pendingHistoryBack ?? Promise.resolve();
}
const currentEntry = () => (window.history.state as Record<string, unknown> | null)?.[STATE_KEY];
interface UseCloseOnBackOptions {
+12 -13
View File
@@ -4,13 +4,10 @@ import { format } from "date-fns";
import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils";
/**
* Returns a memoized function that formats a calendar event date
* using the current locale for day and month names.
*
* The string will be in the format: "EEE, MMM d, yyyy"
*
* For example: "Wed, Apr 29, 2026" (en)
* "Qua, Abr 29, 2026" (pt)
* Returns a memoized function that formats a calendar event date in the
* current locale's own order and names (Intl), e.g.
* "Wed, Apr 29, 2026" (en)
* "Mi., 29. Apr. 2026" (de)
*
* When the Jalali calendar is active (fa locale), the format uses
* Persian day/month names with the Jalali year, e.g.:
@@ -31,12 +28,14 @@ export function useFormatEventDate(): (date: Date) => string {
return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`;
}
const dayOfWeek = format(date, "EEE").toLowerCase();
const month = format(date, "MMM").toLowerCase();
const day = format(date, "d");
const year = format(date, "yyyy");
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
// A display date: its local fields are the wall-clock to show.
return date.toLocaleDateString(locale === "en" ? "en-US" : locale, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
},
[t, isJalali]
[t, isJalali, locale]
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef } from "react";
import { isContextMenuOpen } from "@/components/ui/context-menu";
/** How far the pointer has to move before a preview may open again. */
const MOVE_THRESHOLD = 4;
const MODAL_SELECTOR = '[aria-modal="true"]';
/**
* Decides whether a hover preview may open. Not while an editor, a card,
* a menu or a dialog is open (`overlayOpen` plus any modal on the page),
* and not after one closed until the pointer really moves: the pointer
* resting where the dialog was otherwise "hovers" the event under it and
* the preview opens by itself.
*/
export function useHoverPreviewGate(overlayOpen: boolean): () => boolean {
const overlayOpenRef = useRef(overlayOpen);
const pointer = useRef<{ x: number; y: number } | null>(null);
// Where the pointer was when previews were held back; null when free.
const heldAt = useRef<{ x: number; y: number } | null>(null);
const hold = useCallback(() => {
heldAt.current = pointer.current ?? { x: Number.NaN, y: Number.NaN };
}, []);
useEffect(() => {
const onMove = (e: PointerEvent | MouseEvent) => {
const at = { x: e.clientX, y: e.clientY };
pointer.current = at;
const held = heldAt.current;
if (!held) return;
// Position unknown when it was held: the first move frees it.
if (Number.isNaN(held.x) || Math.hypot(at.x - held.x, at.y - held.y) > MOVE_THRESHOLD) {
heldAt.current = null;
}
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => window.removeEventListener("pointermove", onMove);
}, []);
// An editor, card or quick editor of the calendar closing.
useEffect(() => {
const was = overlayOpenRef.current;
overlayOpenRef.current = overlayOpen;
if (was && !overlayOpen) hold();
}, [overlayOpen, hold]);
// Any other dialog (a confirm, import, shortcuts) portalled onto <body>.
useEffect(() => {
if (typeof MutationObserver === "undefined") return;
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.removedNodes) {
if (node instanceof Element && (node.matches(MODAL_SELECTOR) || node.querySelector(MODAL_SELECTOR))) {
hold();
return;
}
}
}
});
observer.observe(document.body, { childList: true });
return () => observer.disconnect();
}, [hold]);
return useCallback(() => {
if (overlayOpenRef.current || isContextMenuOpen() || document.querySelector(MODAL_SELECTOR)) {
hold();
return false;
}
return heldAt.current === null;
}, [hold]);
}
+3 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useCallback, useRef } from "react";
import { Email } from "@/lib/jmap/types";
import { isEditableEventTarget } from "@/lib/keyboard";
import { isActivatableEventTarget, isEditableEventTarget } from "@/lib/keyboard";
import { keyboardHooks } from "@/lib/plugin-hooks";
export interface KeyboardShortcutHandlers {
@@ -198,6 +198,8 @@ export function useKeyboardShortcuts({
case "enter":
case "o":
// Enter on a focused link or button (the skip link) is the control's.
if (key === "enter" && isActivatableEventTarget(event)) break;
if (selectedEmailId) {
event.preventDefault();
run("open-email", h.onOpenEmail);
+9 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
const LONG_PRESS_DURATION = 500;
const MOVE_THRESHOLD = 10;
@@ -69,5 +69,12 @@ export function useLongPress(
firedRef.current = false;
}, [cancel]);
return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed };
// `handlers` holds only the touch handlers, safe to spread onto a DOM
// element; spreading the whole result would put `isPressed` on it.
const handlers = useMemo(
() => ({ onTouchStart, onTouchEnd, onTouchMove, onTouchCancel }),
[onTouchStart, onTouchEnd, onTouchMove, onTouchCancel]
);
return { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed, handlers };
}
+19 -7
View File
@@ -17,6 +17,11 @@ interface UseTimeGridFocusOptions {
selectedDate: Date;
/** Creates an event at the slot (Enter, `n` or `c`). */
onCreate: (start: Date) => void;
/**
* Whether a day is scrolled into view. The tab stop stays on a day in
* view: one in a hidden week made Tab scroll the grid off its week.
*/
isInView?: (key: string) => boolean;
}
export interface TimeGridSlotProps {
@@ -27,6 +32,7 @@ export interface TimeGridSlotProps {
}
const slotId = (key: string, hour: number) => `${key}T${hour}`;
const everyDay = () => true;
/**
* Keyboard access to the empty slots of the week and day grids (CAL-13,
@@ -35,7 +41,7 @@ const slotId = (key: string, hour: number) => `${key}T${hour}`;
* event there. The month grid works the same way with days.
*/
export function useTimeGridFocus({
rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate,
rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate, isInView = everyDay,
}: UseTimeGridFocusOptions) {
const firstHour = hours.startMinutes / 60;
const lastHour = hours.endMinutes / 60 - 1;
@@ -43,17 +49,23 @@ export function useTimeGridFocus({
const dayIndexByKey = useMemo(() => new Map(days.map((day, index) => [dayKey(day), index])), [days]);
// The slot that takes Tab: the last one focused while it is still drawn,
// else the selected day (or the first loaded one) at the current hour.
// The slot that takes Tab: the last one focused while it is still drawn
// and in view, else the selected day (or the first day in view, else the
// first loaded one) at the current hour.
const tabStop = useMemo(() => {
if (focused && dayIndexByKey.has(focused.key) && focused.hour >= firstHour && focused.hour <= lastHour) {
if (focused && dayIndexByKey.has(focused.key) && isInView(focused.key) && focused.hour >= firstHour && focused.hour <= lastHour) {
return focused;
}
const selectedKey = dayKey(selectedDate);
const key = dayIndexByKey.has(selectedKey) ? selectedKey : days[0] ? dayKey(days[0]) : selectedKey;
const hour = Math.min(lastHour, Math.max(firstHour, displayNow().getHours()));
const firstInView = days.map(dayKey).find(isInView);
const key = dayIndexByKey.has(selectedKey) && isInView(selectedKey)
? selectedKey
: firstInView ?? (days[0] ? dayKey(days[0]) : selectedKey);
const hour = focused && focused.hour >= firstHour && focused.hour <= lastHour
? focused.hour
: Math.min(lastHour, Math.max(firstHour, displayNow().getHours()));
return { key, hour };
}, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days]);
}, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days, isInView]);
const focusSlot = useCallback((key: string, hour: number) => {
const root = rootRef.current;
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { DemoJMAPClient } from '@/lib/demo/demo-client';
// A move, its Undo and a restore from the trash are all parentId updates;
// none of them may make a file look edited.
describe('DemoJMAPClient files', () => {
it('keeps a file\'s Modified time when it is moved or renamed', async () => {
const client = new DemoJMAPClient();
const folder = await client.createFileDirectory('Target', null);
const nodes = await client.listAllFileNodes();
const original = nodes.find((n) => n.blobId != null && n.modified)!;
const before = original.modified;
await client.updateFileNodes({ [original.id]: { parentId: folder.id } });
await client.updateFileNode(original.id, { name: 'renamed.txt' });
const after = (await client.getFileNodes([original.id]))[0];
expect(after.parentId).toBe(folder.id);
expect(after.name).toBe('renamed.txt');
expect(after.modified).toBe(before);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useCloseOnBack } from '@/hooks/use-close-on-back';
import { confirmLeave, hasUnsavedChanges, interceptLeave, registerLeaveGuard } from '@/lib/leave-guard';
const cleanups: Array<() => void> = [];
afterEach(() => { cleanups.splice(0).forEach((off) => off()); });
function guard(dirty: { value: boolean }, answer: boolean) {
const confirm = vi.fn(async () => {
if (answer) dirty.value = false;
return answer;
});
cleanups.push(registerLeaveGuard({ isDirty: () => dirty.value, confirm }));
return confirm;
}
function link() {
const a = document.createElement('a');
const onClick = vi.fn((e: MouseEvent) => { interceptLeave(e as unknown as { preventDefault(): void; currentTarget: EventTarget | null }); });
a.addEventListener('click', onClick);
document.body.appendChild(a);
cleanups.push(() => a.remove());
return { a, onClick };
}
describe('leave guard', () => {
it('lets a click through when nothing is unsaved', () => {
guard({ value: false }, true);
const e = { preventDefault: vi.fn(), currentTarget: null };
expect(interceptLeave(e)).toBe(false);
expect(e.preventDefault).not.toHaveBeenCalled();
});
it('stops the click, asks, and clicks again once the changes are let go', async () => {
const confirm = guard({ value: true }, true);
const { a, onClick } = link();
const click = new MouseEvent('click', { cancelable: true });
a.dispatchEvent(click);
expect(click.defaultPrevented).toBe(true);
expect(confirm).toHaveBeenCalledOnce();
await vi.waitFor(() => expect(onClick).toHaveBeenCalledTimes(2));
expect(hasUnsavedChanges()).toBe(false);
});
it('stays put when the user keeps editing', async () => {
const confirm = guard({ value: true }, false);
const { a, onClick } = link();
a.dispatchEvent(new MouseEvent('click', { cancelable: true }));
await vi.waitFor(() => expect(confirm).toHaveBeenCalledOnce());
await Promise.resolve();
expect(onClick).toHaveBeenCalledTimes(1);
expect(hasUnsavedChanges()).toBe(true);
});
it('leaves only once the asking dialog has taken its history entry back off', async () => {
// The confirm dialog holds a history entry while it is open (Back closes
// it) and steps Back off it as it closes. Leaving before that Back lands
// would be undone by it.
const dialog = renderHook((props: { open: boolean }) => useCloseOnBack({ onBack: () => {}, active: props.open }), {
initialProps: { open: true },
});
await vi.waitFor(() => expect((window.history.state as Record<string, unknown> | null)?.__closeOnBack).toBeTruthy());
const order: string[] = [];
window.addEventListener('popstate', () => order.push('back landed'), { once: true });
cleanups.push(registerLeaveGuard({
isDirty: () => true,
confirm: async () => { dialog.rerender({ open: false }); return true; },
}));
const a = document.createElement('a');
a.addEventListener('click', (e) => { if (!interceptLeave(e)) order.push('left'); });
document.body.appendChild(a);
cleanups.push(() => { a.remove(); dialog.unmount(); });
a.click();
await vi.waitFor(() => expect(order).toContain('left'));
expect(order).toEqual(['back landed', 'left']);
});
it('asks only the editors that have changes', async () => {
const clean = guard({ value: false }, true);
const dirty = guard({ value: true }, true);
expect(await confirmLeave()).toBe(true);
expect(clean).not.toHaveBeenCalled();
expect(dirty).toHaveBeenCalledOnce();
});
});
+36
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { reconcilePushBindings, releasePushBindings, type PushBinding, type PushRole } from '../push-bindings';
import { DemoJMAPClient } from '../demo/demo-client';
/** A client that counts what the reconciliation does to it. */
class FakeClient {
@@ -98,6 +99,41 @@ describe('reconcilePushBindings', () => {
expect(fresh.setups).toBe(1);
});
it('switches between demo logins, which have no live stream', () => {
const work = new DemoJMAPClient('work');
const personal = new DemoJMAPClient('personal');
const bindings = new Map<DemoJMAPClient, PushBinding>();
const roles: Array<[string, PushRole]> = [];
const onError = vi.fn();
const run = (active: string) => reconcilePushBindings({
bindings,
clients: new Map([['work', work], ['personal', personal]]),
activeAccountId: active,
listen: (c, role) => { roles.push([c === work ? 'work' : 'personal', role]); },
onError,
});
run('work');
expect(() => run('personal')).not.toThrow();
expect(() => run('work')).not.toThrow();
expect(onError).not.toHaveBeenCalled();
expect(bindings.get(work)?.role).toBe('active');
expect(bindings.get(personal)?.role).toBe('background');
});
it('treats a client without hasSSEStream as having no stream', () => {
const plain = (name: string) => ({ name, setupPushNotifications: vi.fn(), closePushNotifications: vi.fn() });
const a = plain('a');
const b = plain('b');
const bindings = new Map<ReturnType<typeof plain>, PushBinding>();
const run = (active: string) => reconcilePushBindings({
bindings, clients: new Map([['a', a], ['b', b]]), activeAccountId: active, listen: vi.fn(),
});
run('a');
expect(() => run('b')).not.toThrow();
expect(bindings.get(b)?.role).toBe('active');
expect(bindings.get(a)?.role).toBe('background');
});
it('releases everything on logout', () => {
const { clients, bindings, run } = world(['a', 'b']);
run(['a', 'b'], 'a');
+75 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import en from '@/locales/en/common.json';
import { getByPath } from '../settings-search';
@@ -10,8 +12,14 @@ const text = (key: string) => getByPath(en, key) as string;
// Words that keep their capital inside a sentence-case label.
const PROPER = /^(ASCII-armored|JMAP|Sieve|HTML|PGP|S\/MIME|TOTP|API|IPs|ASCII|BCC|Pro|Junk|Trash|Bulwark|Webmail|Siege|Spam|ID|URL|OAuth\/OIDC\)|\(OAuth\/OIDC\)|Mountain|View|Cc|To|ALL|ANY|Z|→|&)$/;
// Every word after the first, and each part of a hyphenated word after its
// first part: "Sub-Addressing" counts "Addressing".
function titleCaseWords(value: string): string[] {
return value.split(/\s+/).slice(1).filter((w) => /^[A-Z]/.test(w) && !PROPER.test(w.replace(/[(),.]/g, '')));
const words = value.split(/\s+/).flatMap((word, i) => {
const parts = word.split('-');
return i === 0 ? parts.slice(1) : parts;
});
return words.filter((w) => /^[A-Z]/.test(w) && !PROPER.test(w.replace(/[(),.]/g, '')));
}
describe('settings wording', () => {
@@ -21,6 +29,12 @@ describe('settings wording', () => {
expect(text('settings.folders.create_subfolder')).toBe('New subfolder');
expect(text('settings.filters.add_rule')).toBe('New rule');
expect(text('identities.create_new')).toBe('New identity');
expect(text('calendar.management.add_calendar')).toBe('New calendar');
expect(text('settings.security.app_passwords.new')).toBe('New app password');
expect(text('settings.security.api_keys.new')).toBe('New API key');
// A public key and an account exist already: they are added, not created.
expect(text('settings.security.public_keys.add')).toBe('Add public key');
expect(text('settings.account.accounts.add')).toBe('Add account');
expect(text('settings.vacation.save')).toBe('Save');
expect(text('settings.filters.save')).toBe('Save');
expect(text('identities.form.save')).toBe('Save');
@@ -45,6 +59,66 @@ describe('settings wording', () => {
expect(offenders).toEqual([]);
});
// R9: a success toast states the result, without "successfully".
it('reports Settings saves without "successfully"', () => {
for (const key of [
'notifications.filters_saved',
'settings.filters.saved',
'notifications.identity_created',
'notifications.identity_updated',
'notifications.templates_exported',
]) {
expect(text(key), key).not.toMatch(/successful|!/i);
}
expect(text('notifications.filters_saved')).toBe('Filters saved');
});
// Security: App passwords come after the mail client section.
it('sends the mail client hint to the app passwords section below it', () => {
expect(text('settings.security.email_client.password_instructions')).toContain('in the section below');
});
// Settings > Contacts deletes address books and categories as well.
it('says the contact lists can be deleted, not only renamed', () => {
expect(text('settings.contacts.manage_description')).toBe('Rename or delete your address books');
expect(text('settings.contacts.categories_description')).toBe('Rename or delete contact categories');
});
// R5: every editor asks the same question with the same buttons, in every
// language: Settings and Contacts say what Calendar says.
it('asks "Discard changes?" with Keep editing / Discard everywhere', () => {
expect(text('calendar.discard.title')).toBe('Discard changes?');
const mismatches: string[] = [];
for (const locale of readdirSync(join(process.cwd(), 'locales'))) {
const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8'));
const ref = getByPath(messages, 'calendar.discard') as Record<string, string>;
const expected: Record<string, string> = {
'settings.discard_changes': ref.title,
'settings.unsaved_changes': ref.message,
'settings.keep_editing': ref.keep,
'settings.discard': ref.confirm,
'contacts.form.discard_title': ref.title,
'contacts.form.discard_message': ref.message,
'contacts.form.keep_editing': ref.keep,
'contacts.form.discard': ref.confirm,
};
for (const [key, value] of Object.entries(expected)) {
if (getByPath(messages, key) !== value) mismatches.push(`${locale} ${key}`);
}
}
expect(mismatches).toEqual([]);
});
it('counts every word of a hyphenated label', () => {
expect(titleCaseWords('Sub-Addressing')).toEqual(['Addressing']);
expect(titleCaseWords('Sub-addressing')).toEqual([]);
});
it('says "Back to mail" in Settings, the admin and plugin pages', () => {
expect(text('settings.back_to_mail')).toBe('Back to mail');
expect(text('plugin_pages.back_to_mail')).toBe('Back to mail');
});
it('does not repeat the debug section title as its only row', () => {
expect(text('settings.advanced.debug_mode.toggle')).not.toBe(text('settings.advanced.debug_mode.label'));
});
+1 -1
View File
@@ -8,7 +8,7 @@
import { useEffect, useRef } from 'react';
export type AppCommand = 'compose' | 'search' | 'all-inboxes';
export type AppCommand = 'compose' | 'search' | 'all-inboxes' | 'show-list';
export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette';

Some files were not shown because too many files have changed in this diff Show More