mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
Merge branch 'main' into ux/mail3
This commit is contained in:
@@ -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({});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }} /></>);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'); });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user