Merge branch 'main' into ux/mail2

This commit is contained in:
Linus Rath
2026-10-28 08:16:51 +01:00
61 changed files with 1702 additions and 162 deletions
@@ -222,6 +222,15 @@ describe('CalendarApp unsaved editor changes', () => {
await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull());
});
it('opens the editor on n without typing the n into its title', async () => {
renderApp();
const press = new KeyboardEvent('keydown', { key: 'n', code: 'KeyN', bubbles: true, cancelable: true });
document.body.dispatchEvent(press);
// A key whose keydown is not cancelled goes on to type its character
expect(press.defaultPrevented).toBe(true);
expect(await screen.findByPlaceholderText('form.title')).toHaveValue('');
});
it('closes an untouched editor without asking', () => {
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
@@ -254,6 +263,90 @@ describe('CalendarApp unsaved editor changes', () => {
});
});
describe('CalendarApp browser Back with an editor open (R5)', () => {
const backEntry = () => (window.history.state as Record<string, unknown> | null)?.__closeOnBack;
// Earlier tests leave history behind: wait for this editor's own entry.
const openEditorEntry = async () => {
const before = backEntry();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
await waitFor(() => expect(backEntry()).toBeTruthy());
await waitFor(() => expect(backEntry()).not.toBe(before));
};
it('closes an untouched editor instead of leaving the calendar', async () => {
renderApp();
await openEditorEntry();
window.history.back();
await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull());
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(backEntry()).toBeUndefined();
});
it('asks before Back drops changes, and asks again after Keep editing', async () => {
renderApp();
await openEditorEntry();
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Lunch' } });
window.history.back();
const ask = await screen.findByRole('alertdialog', { name: 'discard.title' });
fireEvent.click(within(ask).getByRole('button', { name: 'discard.keep' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(screen.getByPlaceholderText('form.title')).toHaveValue('Lunch');
// The entry is back, so the next Back asks again
expect(backEntry()).toBeTruthy();
window.history.back();
const again = await screen.findByRole('alertdialog', { name: 'discard.title' });
fireEvent.click(within(again).getByRole('button', { name: 'discard.confirm' }));
await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull());
await waitFor(() => expect(backEntry()).toBeUndefined());
});
it('takes its history entry back off when the editor closes another way', async () => {
renderApp();
await openEditorEntry();
fireEvent.click(screen.getByText('form.cancel'));
await waitFor(() => expect(backEntry()).toBeUndefined());
});
});
describe('CalendarApp event search (CAL-16)', () => {
it('focuses the search on / and shows matching events in place of the grid', async () => {
setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Dentist', start: '2026-10-13T08:00:00' })]);
renderApp();
fireEvent.keyDown(document.body, { key: '/', code: 'Slash' });
const box = screen.getByRole('searchbox', { name: 'search.title' });
expect(document.activeElement).toBe(box);
fireEvent.change(box, { target: { value: 'dent' } });
const results = await screen.findByTestId('calendar-search-results');
expect(within(results).getByText('Dentist')).toBeInTheDocument();
expect(within(results).queryByText('Standup')).toBeNull();
expect(screen.queryByRole('grid')).toBeNull();
// A result opens like an event in the grid
fireEvent.click(within(results).getByText('Dentist'));
expect(await screen.findByRole('dialog', { name: 'Dentist' })).toBeInTheDocument();
fireEvent.keyDown(document.body, { key: 'Escape' });
// Escape in the box goes back to the grid
fireEvent.keyDown(box, { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull());
expect(screen.getByRole('grid')).toBeInTheDocument();
});
it('says so when nothing matches', async () => {
renderApp();
fireEvent.change(screen.getByRole('searchbox', { name: 'search.title' }), { target: { value: 'zzz' } });
expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument();
});
it('is in the command palette', () => {
render(<><CalendarApp /><PaletteGroups /></>);
const items = screen.getByTestId('palette').textContent ?? '';
expect(items).toContain('calendar: search.title');
});
});
function toastTitles(): string[] {
return useToastStore.getState().toasts.map((t) => t.title);
}
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import { CalendarDayView } from '../calendar-day-view';
import { useSettingsStore } from '@/stores/settings-store';
import { fillHourHeight } from '@/hooks/use-display-hours';
import type { CalendarEvent, Calendar } from '@/lib/jmap/types';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
@@ -154,3 +155,79 @@ describe('calendar display hours and working days (#1164)', () => {
expect(document.querySelector('[data-hidden-events="before"]')).toHaveTextContent('1');
});
});
describe('time grid height (CAL-27)', () => {
// A tablet-tall pane: the grid's scroll root reports 1100px of room and
// its sticky header 100px, leaving 1000px for 12 hours.
beforeEach(() => {
useSettingsStore.setState(initialSettings, true);
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'grid' ? 1100 : 0;
});
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
return this.classList.contains('sticky') && this.classList.contains('top-0') ? 100 : 0;
});
});
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
it('stretches the hour rows to fill the pane below a short range', () => {
renderWeek({ events: [makeEvent('noon', '2026-09-09T12:00:00', 'PT1H')] });
// floor(1000 / 12) = 83px an hour instead of leaving 280px empty
expect(screen.getAllByRole('gridcell')[0].style.height).toBe('83px');
expect(eventBlock('noon')?.style.top).toBe(`${4 * 83}px`);
expect(eventBlock('noon')?.style.height).toBe('83px');
});
it('fits the rows to the pane, shrinking them only a little', () => {
expect(fillHourHeight(60, 1000, 12)).toBe(83);
// 59px rows instead of 4px of scrolling
expect(fillHourHeight(60, 716, 12)).toBe(59);
// a whole day on a short pane scrolls at the normal height
expect(fillHourHeight(60, 716, 24)).toBe(60);
expect(fillHourHeight(60, 0, 12)).toBe(60);
});
it('keeps the normal row height when the whole day is shown', () => {
useSettingsStore.setState({ calendarLimitHours: false });
renderWeek();
expect(screen.getAllByRole('gridcell')[0].style.height).toBe('60px');
});
it('marks events that run past the visible hours instead of cutting them silently', () => {
renderWeek({
events: [
makeEvent('late', '2026-09-09T19:00:00', 'PT2H'),
makeEvent('early', '2026-09-09T07:00:00', 'PT2H'),
makeEvent('inside', '2026-09-09T12:00:00', 'PT1H'),
],
});
const card = (id: string) => screen.getByText('Event ' + id).closest('button')!;
expect(card('late')).toHaveAttribute('data-continues-after');
expect(card('late')).not.toHaveAttribute('data-continues-before');
expect(card('early')).toHaveAttribute('data-continues-before');
expect(card('inside')).not.toHaveAttribute('data-continues-after');
expect(card('inside')).not.toHaveAttribute('data-continues-before');
});
it('fills the day view too', () => {
render(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-09"
rangeStart={new Date(2026, 8, 9)}
rangeEnd={new Date(2026, 8, 9)}
selectedDate={new Date(2026, 8, 9)}
events={[makeEvent('late', '2026-09-09T19:30:00', 'PT2H')]}
calendars={calendars}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
expect(screen.getAllByRole('gridcell')[0].style.height).toBe('83px');
expect(screen.getByText('Event late').closest('button')).toHaveAttribute('data-continues-after');
});
});
@@ -0,0 +1,166 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import { CalendarDayView } from '../calendar-day-view';
import { useSettingsStore } from '@/stores/settings-store';
import type { Calendar } from '@/lib/jmap/types';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
const initialSettings = useSettingsStore.getState();
type WeekProps = React.ComponentProps<typeof CalendarWeekView>;
function renderWeek(overrides: Partial<WeekProps> = {}) {
const props: WeekProps = {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'week:2026-09-07',
// Monday 7 Sep to Sunday 13 Sep 2026
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 13),
selectedDate: new Date(2026, 8, 9),
events: [],
calendars,
onSelectDate: vi.fn(),
onSelectEvent: vi.fn(),
onCreateAtTime: vi.fn(),
...overrides,
};
render(<CalendarWeekView {...props} />);
return props;
}
function tabStops(): HTMLElement[] {
return screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0);
}
function focusedSlot(): string | null {
return (document.activeElement as HTMLElement | null)?.getAttribute('data-slot') ?? null;
}
describe('keyboard access to the time grid (CAL-13)', () => {
beforeEach(() => {
useSettingsStore.setState(initialSettings, true);
});
afterEach(() => {
cleanup();
});
it('gives the week grid one tab stop on the selected day', () => {
renderWeek();
const stops = tabStops();
expect(stops).toHaveLength(1);
expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-09T\d+$/);
// The slot is read out with its date and time (the test formatter
// prints the whole Date), not just "Wed 08:00"
expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/);
// ...and one date in the header, not each loaded day's
const dates = screen.getAllByRole('columnheader').map((h) => h.querySelector('button')!);
expect(dates.filter((b) => b.tabIndex === 0)).toHaveLength(1);
});
it('moves by hour and day with the arrows and Home/End', () => {
renderWeek();
const start = tabStops()[0];
start.focus();
fireEvent.keyDown(start, { key: 'Home' });
expect(focusedSlot()).toBe('2026-09-09T8');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
expect(focusedSlot()).toBe('2026-09-09T9');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
expect(focusedSlot()).toBe('2026-09-10T9');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
expect(focusedSlot()).toBe('2026-09-08T9');
fireEvent.keyDown(document.activeElement!, { key: 'End' });
expect(focusedSlot()).toBe('2026-09-08T19');
// The edge of the visible hours stops the arrow
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
expect(focusedSlot()).toBe('2026-09-08T19');
// The tab stop follows the focus
expect(tabStops().map((cell) => cell.getAttribute('data-slot'))).toEqual(['2026-09-08T19']);
});
it('keeps the arrows from paging the calendar', () => {
renderWeek();
const start = tabStops()[0];
start.focus();
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true });
start.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('creates an event at the focused slot with Enter or n', () => {
const props = renderWeek();
const start = tabStops()[0];
start.focus();
fireEvent.keyDown(start, { key: 'Home' });
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 8, 0, 0, 0));
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN' });
expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 9, 0, 0, 0));
// Not with a modifier: Ctrl+N belongs to the browser
fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN', ctrlKey: true });
expect(props.onCreateAtTime).toHaveBeenCalledTimes(2);
});
it('leaves the slots out of the tab order on phones', () => {
renderWeek({ isMobile: true });
expect(tabStops()).toHaveLength(0);
});
it('works the same in the day view', () => {
const onCreateAtTime = vi.fn();
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={[]}
calendars={calendars}
onSelectEvent={vi.fn()}
onCreateAtTime={onCreateAtTime}
/>,
);
const stops = tabStops();
expect(stops).toHaveLength(1);
expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/);
stops[0].focus();
fireEvent.keyDown(stops[0], { key: 'End' });
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
expect(focusedSlot()).toBe('2026-09-10T19');
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
expect(onCreateAtTime).toHaveBeenCalledWith(new Date(2026, 8, 10, 19, 0, 0, 0));
});
});
describe('quick editor focus (CAL-13)', () => {
afterEach(() => {
cleanup();
});
it('returns focus to the slot it was opened from', async () => {
const { QuickCreatePopover } = await import('../quick-create-popover');
const slot = document.createElement('div');
slot.tabIndex = -1;
document.body.appendChild(slot);
slot.focus();
const { unmount } = render(
<QuickCreatePopover
draft={{ start: new Date(2026, 8, 9, 10), end: new Date(2026, 8, 9, 11), title: '', allDay: false, calendarId: 'cal-1' }}
calendars={calendars}
onChange={vi.fn()}
onSave={vi.fn()}
onMoreOptions={vi.fn()}
onClose={vi.fn()}
/>,
);
(screen.getAllByRole('textbox')[0] as HTMLElement).focus();
unmount();
expect(document.activeElement).toBe(slot);
slot.remove();
});
});
+86 -8
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw } from "@/components/icons";
import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw, Search } from "@/components/icons";
import {
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
format, parseISO,
@@ -86,6 +86,9 @@ import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links";
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
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 {
@@ -191,6 +194,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const [showEventModal, setShowEventModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
// Event search (CAL-16): the query in the toolbar box; on phones the box
// opens as a row of its own.
const [searchQuery, setSearchQuery] = useState("");
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null);
const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false);
@@ -942,6 +950,23 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
: proInterfaceActive ? null : calendarLinkPath,
);
// Browser Back closes the event or task editor (asking first when it has
// unsaved changes) instead of leaving the calendar (R5).
const closeEditors = useCallback(() => {
setShowEventModal(false);
setEditEvent(null);
setPendingPreview(null);
setDefaultCalendarIdForCreate(undefined);
setDefaultModalAllDay(false);
setShowTaskModal(false);
setEditTask(null);
}, []);
useCloseOnBack({
active: (showEventModal || showTaskModal) && (isEmbedded ? isFocusedProTab : !proInterfaceActive),
shouldHold: () => eventEditorDirtyRef.current || taskEditorDirtyRef.current,
onBack: () => whenEditorsDiscarded(closeEditors),
});
const handleEditFromDetail = useCallback(() => {
if (detailEvent) {
const ev = detailEvent;
@@ -1512,6 +1537,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
await handleRsvp(detailEvent.id, participantId, status);
}, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]);
// `/` and the palette's "Search events" go to the search box.
const openSearch = useCallback(() => {
if (isMobile) { setMobileSearchOpen(true); return; }
searchInputRef.current?.focus();
searchInputRef.current?.select();
}, [isMobile]);
// Moving the calendar (a view, a date) goes back to the grid.
const leaveSearch = useCallback(() => {
setSearchQuery("");
setMobileSearchOpen(false);
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
@@ -1524,6 +1561,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const action = calendarShortcutAction(e);
if (!action) return;
if (action !== "new" && action !== "search") leaveSearch();
switch (action) {
case "prev": e.preventDefault(); navigatePrev(); break;
case "next": e.preventDefault(); navigateNext(); break;
@@ -1533,12 +1571,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
case "day": setViewMode("day"); break;
case "agenda": setViewMode("agenda"); break;
case "tasks": if (enableCalendarTasks) setViewMode("tasks"); break;
case "new": openCreateForView(); break;
// Kept from typing itself into the title field the editor focuses.
case "new": e.preventDefault(); openCreateForView(); break;
case "search": e.preventDefault(); openSearch(); break;
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateForView, showEventModal, showTaskModal, detailPinned, enableCalendarTasks]);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateForView, openSearch, leaveSearch, showEventModal, showTaskModal, detailPinned, enableCalendarTasks]);
// The command palette's Calendar group, while this calendar is the one in
// front (in the Pro shell: its focused tab).
@@ -1553,13 +1593,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
useRegisterCommands(tSidebar("calendar"), [
{ id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() },
{ id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks },
{ id: "search-events", label: t("search.title"), icon: Search, shortcut: "/", run: openSearch },
{ id: "today", label: t("events.go_to_today"), icon: CalendarDays, shortcut: "t", run: goToToday, when: () => normalizedViewMode !== "tasks" },
...paletteViews.map(({ mode, key }) => ({
id: `view-${mode}`,
label: t(`commands.${mode}`),
icon: CalendarDays,
shortcut: key,
run: () => { setMobileReturnToMonth(false); setViewMode(mode); },
run: () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); },
when: () => mode !== normalizedViewMode && (mode !== "tasks" || enableCalendarTasks),
})),
{ id: "import", label: t("toolbar.import"), icon: Upload, run: () => setShowImportModal(true), when: () => !demoMode },
@@ -1572,6 +1613,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return generateBirthdayEvents(contacts, dateRange.start, dateRange.end);
}, [showBirthdayCalendar, contacts, dateRange]);
// Loaded events answer a search at once; the server adds the rest.
const searchableEvents = useMemo(() => [...events, ...birthdayEvents], [events, birthdayEvents]);
const searchResults = useCalendarEventSearch(searchQuery, client, searchableEvents);
const birthdayCalendarName = (() => {
try { return t('birthday_calendar'); } catch { return 'Birthdays'; }
})();
@@ -1735,6 +1780,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
const viewContent = (() => {
if (searchResults && normalizedViewMode !== "tasks") {
return (
<CalendarSearchResults
query={searchQuery}
results={searchResults}
calendars={allCalendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onContextMenuEvent={handleContextMenuEvent}
/>
);
}
switch (normalizedViewMode) {
case "month":
return (
@@ -1893,10 +1951,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
selectedDate={selectedDate}
visibleDate={visibleDate}
viewMode={normalizedViewMode}
onPrev={navigatePrev}
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onPrev={() => { leaveSearch(); navigatePrev(); }}
onNext={() => { leaveSearch(); navigateNext(); }}
onToday={() => { leaveSearch(); goToToday(); }}
onViewModeChange={(mode) => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }}
onCreateEvent={openCreateForView}
createLabel={createLabel}
onImport={demoMode ? undefined : () => setShowImportModal(true)}
@@ -1912,6 +1970,26 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed}
menuTogglesSidebar={!isNarrow}
showCreateButton={isNarrow || sidebarCollapsed}
// Tasks have their own list and no events to search.
searchField={normalizedViewMode === "tasks" ? undefined : (
<CalendarSearchField
ref={searchInputRef}
value={searchQuery}
onChange={setSearchQuery}
className="w-40 lg:w-56 xl:w-64 me-1"
/>
)}
onOpenSearch={normalizedViewMode === "tasks" ? undefined : () => setMobileSearchOpen(true)}
mobileSearchField={mobileSearchOpen ? (
<CalendarSearchField
ref={searchInputRef}
value={searchQuery}
onChange={setSearchQuery}
onDismiss={leaveSearch}
autoFocus
/>
) : null}
onCloseSearch={leaveSearch}
/>
);
+36 -18
View File
@@ -15,6 +15,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
import { useTimeGridFocus } from "@/hooks/use-time-grid-focus";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -57,7 +58,7 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
revealAt?: RevealTime | null;
}
const HOUR_HEIGHT = 64;
const MIN_HOUR_HEIGHT = 64;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const FALLBACK_COL_WIDTH = 600;
@@ -100,6 +101,8 @@ export function CalendarDayView({
// the hours scroll down, and the sticky header block and hour gutter stay
// put. (A nested vertical scroller would capture the gutter's stickiness.)
const rootRef = useRef<HTMLDivElement>(null);
// The sticky date header (and all-day row): the hour rows fill what is left.
const headerRef = useRef<HTMLDivElement>(null);
const startSentinelRef = useRef<HTMLDivElement>(null);
const endSentinelRef = useRef<HTMLDivElement>(null);
const gutterWidth = isMobile ? 40 : 64;
@@ -176,11 +179,11 @@ export function CalendarDayView({
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt });
hours, hourHeight, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, headerRef, minHourHeight: MIN_HOUR_HEIGHT, revealAt });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
const gridHeight = visibleHours.length * hourHeight;
// Column layouts are the costly part of a render; with months of columns
// they must not be redone on every scroll-driven re-render.
@@ -278,7 +281,7 @@ export function CalendarDayView({
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
} = useTimeGridInteractions({
hourHeight: HOUR_HEIGHT,
hourHeight,
gridStartMinutes: hours.startMinutes,
gridEndMinutes: hours.endMinutes,
calendars,
@@ -295,6 +298,16 @@ export function CalendarDayView({
onMoveEvent,
});
const { slotProps } = useTimeGridFocus({
rootRef,
headerRef,
gutterWidth,
days,
hours,
selectedDate,
onCreate: onCreateAtTime,
});
const formatHour = (h: number): string => {
if (timeFormat === "12h") {
const d = new Date(2000, 0, 1, h);
@@ -302,6 +315,8 @@ export function CalendarDayView({
}
return format(new Date(2000, 0, 1, h), "HH:mm");
};
// A slot is read out with its whole date: "Wednesday, October 28, 10:00".
const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" });
// Above every in-column overlay (events z-10, handles z-20, drag z-30) so
// columns scrolled past the start do not show through the gutter.
@@ -319,7 +334,7 @@ export function CalendarDayView({
<div ref={startSentinelRef} data-testid="day-start-sentinel" className="absolute inset-y-0 start-0 w-px pointer-events-none" />
<div ref={endSentinelRef} data-testid="day-end-sentinel" className="absolute inset-y-0 end-0 w-px pointer-events-none" />
<div className="sticky top-0 z-50 flex border-b border-border bg-background">
<div ref={headerRef} className="sticky top-0 z-50 flex border-b border-border bg-background">
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
<AllHoursToggle
@@ -426,7 +441,7 @@ export function CalendarDayView({
<div
key={h}
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
style={{ height: hourHeight }}
>
{/* Midnight goes unlabelled; a later first hour is labelled
inside its row, where the header cannot cover it. */}
@@ -467,12 +482,13 @@ export function CalendarDayView({
<div
key={h}
role="gridcell"
aria-label={formatHour(h)}
aria-label={`${slotDateLabel(day)}, ${formatHour(h)}`}
{...slotProps(day, h, !isMobile)}
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"
style={{ height: HOUR_HEIGHT }}
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"
style={{ height: hourHeight }}
/>
))}
@@ -494,10 +510,10 @@ export function CalendarDayView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const clip = clipToDisplayHours(startMinutes, endMinutes, hours);
const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT);
const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * hourHeight);
// Short events at the bottom edge stay inside the grid.
const baseTop = Math.max(0, Math.min(
((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
((clip.startMinutes - hours.startMinutes) / 60) * hourHeight,
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
@@ -526,6 +542,8 @@ export function CalendarDayView({
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable={editable}
continuesBefore={clip.clippedStart}
continuesAfter={clip.clippedEnd}
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{editable && !clip.clippedStart && (
@@ -559,7 +577,7 @@ export function CalendarDayView({
{today && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
<div
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * hourHeight }}
>
<div className="flex items-center -translate-y-1/2">
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
@@ -580,8 +598,8 @@ export function CalendarDayView({
<div
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
style={{
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * hourHeight,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * hourHeight,
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
@@ -593,7 +611,7 @@ export function CalendarDayView({
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * hourHeight }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
@@ -621,8 +639,8 @@ export function CalendarDayView({
data-pending-preview
className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md"
style={{
top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT),
top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight,
height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight),
backgroundColor: color,
color: readableTextOn(color),
}}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { forwardRef, useMemo, useRef, type KeyboardEvent } from "react";
import { useTranslations } from "next-intl";
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 { EmptyState } from "@/components/ui/empty-state";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { isDeclinedByUser } from "@/lib/calendar-participants";
import { isRecurringEvent } from "@/lib/global-search/providers/calendar";
import { displayNow } from "@/lib/timezone";
import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
import type { CalendarSearchResults as Results } from "@/hooks/use-calendar-event-search";
import { getEventColor } from "./event-card";
const RESULT_SELECTOR = "[data-search-result]";
interface CalendarSearchFieldProps {
value: string;
onChange: (value: string) => void;
/** Escape on an empty field, or the phone's close: leave the search. */
onDismiss?: () => void;
autoFocus?: boolean;
className?: string;
}
/** The search box in the calendar toolbar (CAL-16). */
export const CalendarSearchField = forwardRef<HTMLInputElement, CalendarSearchFieldProps>(
function CalendarSearchField({ value, onChange, onDismiss, autoFocus, className }, ref) {
const t = useTranslations("calendar");
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
// Escape clears the search first, then leaves the field (R8).
e.preventDefault();
e.stopPropagation();
if (value) onChange("");
else { e.currentTarget.blur(); onDismiss?.(); }
} else if (e.key === "ArrowDown") {
const first = document.querySelector<HTMLElement>(RESULT_SELECTOR);
if (first) { e.preventDefault(); first.focus(); }
}
};
return (
<div className={cn("relative", className)} role="search">
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" aria-hidden="true" />
{/* type="search" keeps password managers out of it, as in Mail. */}
<Input
ref={ref}
type="search"
value={value}
autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("search.title")}
aria-label={t("search.title")}
autoComplete="off"
data-calendar-search
className={cn(
"h-8 ps-8 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none",
value && "pe-8",
)}
/>
{value && (
<IconButton
label={t("search.clear")}
size="icon-sm"
className="absolute end-0 top-1/2 -translate-y-1/2"
onClick={() => onChange("")}
>
<X />
</IconButton>
)}
</div>
);
},
);
interface CalendarSearchResultsProps {
query: string;
results: Results;
calendars: Calendar[];
timeFormat?: "12h" | "24h";
currentUserEmails?: string[];
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
}
/** The events a calendar search found, upcoming first, then past ones. */
export function CalendarSearchResults({
query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent,
}: CalendarSearchResultsProps) {
const t = useTranslations("calendar");
const intlFormatter = useDisplayDateFormatter();
const listRef = useRef<HTMLDivElement>(null);
const calendarMap = useMemo(() => new Map(calendars.map((c) => [c.id, c])), [calendars]);
const { upcoming, past, searching } = results;
const count = upcoming.length + past.length;
const thisYear = displayNow().getFullYear();
const formatTime = (date: Date) => timeFormat === "12h"
? intlFormatter.dateTime(date, { hour: "numeric", minute: "2-digit", hour12: true })
: format(date, "HH:mm");
// ↑/↓ walk the list, ↑ on the first row goes back to the search box (R8).
const handleRowKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const rows = Array.from(listRef.current?.querySelectorAll<HTMLElement>(RESULT_SELECTOR) ?? []);
const index = rows.indexOf(e.currentTarget);
const next = rows[index + (e.key === "ArrowDown" ? 1 : -1)];
if (next) next.focus();
else if (e.key === "ArrowUp") document.querySelector<HTMLElement>("[data-calendar-search]")?.focus();
};
const renderRow = (ev: CalendarEvent) => {
const calId = getPrimaryCalendarId(ev);
const calendar = calId ? calendarMap.get(calId) : undefined;
const color = getEventColor(ev, calendar);
const start = getEventStartDate(ev);
const end = getEventEndDate(ev);
const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails);
const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null;
const meta = [locationName, calendar?.name].filter(Boolean).join(" · ");
// A series the server found by its first occurrence.
const repeats = !ev.recurrenceId && isRecurringEvent(ev);
const sameYear = start.getFullYear() === thisYear;
const title = ev.title || t("events.no_title");
return (
<button
key={ev.id}
type="button"
data-search-result
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
onKeyDown={handleRowKeyDown}
className="w-full flex items-start gap-3 px-2 sm:px-4 rounded-md hover:bg-muted/60 focus-visible:bg-muted/60 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring transition-colors text-start"
style={{ paddingBlock: "var(--density-item-py)" }}
>
<span className="w-24 sm:w-32 flex-shrink-0 text-sm leading-tight">
<span className="block text-foreground">
{intlFormatter.dateTime(start, sameYear
? { weekday: "short", month: "short", day: "numeric" }
: { 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)}`}
</span>
</span>
<span
className="w-[9px] h-[9px] mt-[5.5px] rounded-full flex-shrink-0"
style={isInactive ? { boxShadow: `inset 0 0 0 1.5px ${color}` } : { backgroundColor: color }}
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>
{(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>}
{repeats && (
<span className="flex items-center gap-1 flex-shrink-0">
<Repeat className="w-3 h-3" aria-hidden="true" />
{t("search.repeats")}
</span>
)}
</span>
)}
</span>
</button>
);
};
const section = (id: string, label: string, events: CalendarEvent[]) => events.length > 0 && (
<section aria-labelledby={id} className="py-1">
<h2 id={id} className="px-2 sm:px-4 pt-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</h2>
<div className="px-1 sm:px-2">{events.map(renderRow)}</div>
</section>
);
return (
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto" data-testid="calendar-search-results">
<p className="sr-only" role="status" aria-live="polite">
{searching && count === 0 ? "" : t("search.results", { count })}
</p>
{count === 0 && searching && (
<div className="flex justify-center p-8"><Spinner /></div>
)}
{count === 0 && !searching && (
<EmptyState
icon={Search}
title={t("search.no_results", { query: query.trim() })}
description={t("search.no_results_hint")}
/>
)}
{section("calendar-search-upcoming", t("search.upcoming"), upcoming)}
{section("calendar-search-past", t("search.past"), past)}
{count > 0 && searching && (
<div className="flex justify-center py-3"><Spinner size="sm" /></div>
)}
</div>
);
}
+33 -3
View File
@@ -1,10 +1,10 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw } from "@/components/icons";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search } from "@/components/icons";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { startOfWeek } from "date-fns";
@@ -49,6 +49,14 @@ interface CalendarToolbarProps {
* at the top of the sidebar, so this is only needed while it is hidden.
*/
showCreateButton?: boolean;
/** The search box, shown before the ⋮ on wider screens. */
searchField?: ReactNode;
/** Phones: opens the search row in place of the date row. */
onOpenSearch?: () => void;
/** Phones: the open search row's field; null while it is closed. */
mobileSearchField?: ReactNode;
/** Phones: closes the search row. */
onCloseSearch?: () => void;
}
// Shortcut keys handled by the calendar app (see its keydown handler).
@@ -84,6 +92,10 @@ export function CalendarToolbar({
sidebarOpen,
menuTogglesSidebar,
showCreateButton,
searchField,
onOpenSearch,
mobileSearchField,
onCloseSearch,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
@@ -204,7 +216,16 @@ export function CalendarToolbar({
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
{/* Row 1 while searching: the search box with a way back. */}
{mobileSearchField ? (
<div className="flex items-center gap-1">
<IconButton size="icon-lg" label={t("search.close")} onClick={onCloseSearch} className="-ms-1 touch-manipulation">
<ArrowLeft />
</IconButton>
<div className="flex-1 min-w-0">{mobileSearchField}</div>
</div>
) : (
/* Row 1: Back / Date nav / Today */
<div className="flex items-center gap-1">
{onMenuClick && (
<IconButton size="icon-lg" label={t("nav_open_menu")} onClick={onMenuClick} className="-ms-1 touch-manipulation">
@@ -237,6 +258,7 @@ export function CalendarToolbar({
{/* The shell's phone ⋮ (notifications, palette, account) with the calendar's own entries on top. */}
<PhoneHeaderActions below="md" menuItems={phoneMenuItems} />
</div>
)}
{/* Row 2: View switcher pills + calendar toggle */}
<div className="flex items-center gap-1.5">
@@ -257,6 +279,12 @@ export function CalendarToolbar({
))}
</div>
{/* Beside the calendar filter: the date row has no room left. */}
{onOpenSearch && (
<IconButton size="icon-lg" variant="outline" label={t("search.title")} onClick={onOpenSearch} className="touch-manipulation">
<Search />
</IconButton>
)}
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<IconButton
@@ -419,6 +447,8 @@ export function CalendarToolbar({
<div className="flex-1" />
{searchField}
{moreButton}
<div className="relative ms-1">
+38 -18
View File
@@ -18,6 +18,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
import { useTimeGridFocus } from "@/hooks/use-time-grid-focus";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -66,7 +67,7 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
revealAt?: RevealTime | null;
}
const HOUR_HEIGHT = 60;
const MIN_HOUR_HEIGHT = 60;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MOBILE_COL_WIDTH = 120;
const MIN_COL_WIDTH = 80;
@@ -114,6 +115,8 @@ export function CalendarWeekView({
// the hours scroll down, and the sticky header rows and hour gutter stay
// put. (A nested vertical scroller would capture the gutter's stickiness.)
const rootRef = useRef<HTMLDivElement>(null);
// The sticky date header (and all-day row): the hour rows fill what is left.
const headerRef = useRef<HTMLDivElement>(null);
const startSentinelRef = useRef<HTMLDivElement>(null);
const endSentinelRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
@@ -211,11 +214,11 @@ export function CalendarWeekView({
}, [events]);
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt });
hours, hourHeight, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, headerRef, minHourHeight: MIN_HOUR_HEIGHT, revealAt });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
const gridHeight = visibleHours.length * hourHeight;
// Column layouts are the costly part of a render; with months of columns
// they must not be redone on every scroll-driven re-render.
@@ -374,7 +377,7 @@ export function CalendarWeekView({
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
} = useTimeGridInteractions({
hourHeight: HOUR_HEIGHT,
hourHeight,
gridStartMinutes: hours.startMinutes,
gridEndMinutes: hours.endMinutes,
calendars,
@@ -391,6 +394,16 @@ export function CalendarWeekView({
onMoveEvent,
});
const { slotProps, tabStopDayKey } = useTimeGridFocus({
rootRef,
headerRef,
gutterWidth,
days,
hours,
selectedDate,
onCreate: onCreateAtTime,
});
const formatHour = (h: number): string => {
if (timeFormat === "12h") {
const d = new Date(2000, 0, 1, h);
@@ -398,6 +411,8 @@ export function CalendarWeekView({
}
return format(new Date(2000, 0, 1, h), "HH:mm");
};
// A slot is read out with its whole date: "Wednesday, October 28, 10:00".
const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" });
// Above every in-column overlay (events z-10, handles z-20, drag z-30) so
// columns scrolled past the start do not show through the gutter.
@@ -414,7 +429,7 @@ export function CalendarWeekView({
<div className="relative flex flex-col" style={{ width: stripWidth, minWidth: stripWidth }}>
<div ref={startSentinelRef} data-testid="week-start-sentinel" className="absolute inset-y-0 start-0 w-px pointer-events-none" />
<div ref={endSentinelRef} data-testid="week-end-sentinel" className="absolute inset-y-0 end-0 w-px pointer-events-none" />
<div className="sticky top-0 z-50 bg-background">
<div ref={headerRef} className="sticky top-0 z-50 bg-background">
<div className="flex border-b border-border" role="row">
<div className={cn(gutterClass, "flex items-end justify-center pb-1")}>
{canToggleHours && (
@@ -449,6 +464,8 @@ export function CalendarWeekView({
<button
type="button"
onClick={() => (onOpenDay ?? onSelectDate)(day)}
// One date in the tab order, not every loaded day's.
tabIndex={dayKey(day) === tabStopDayKey ? 0 : -1}
aria-label={fullLabel}
aria-current={todayCol ? "date" : undefined}
className={cn(
@@ -608,7 +625,7 @@ export function CalendarWeekView({
<div
key={h}
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
style={{ height: hourHeight }}
>
{/* Midnight goes unlabelled; a later first hour is labelled
inside its row, where the header cannot cover it. */}
@@ -649,12 +666,13 @@ export function CalendarWeekView({
<div
key={h}
role="gridcell"
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
aria-label={`${slotDateLabel(day)}, ${formatHour(h)}`}
{...slotProps(day, h, !isMobile)}
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"
style={{ height: HOUR_HEIGHT }}
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"
style={{ height: hourHeight }}
/>
))}
@@ -676,10 +694,10 @@ export function CalendarWeekView({
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
const durMin = Math.max(15, endMinutes - startMinutes);
const clip = clipToDisplayHours(startMinutes, endMinutes, hours);
const baseHeight = Math.max(20, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT);
const baseHeight = Math.max(20, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * hourHeight);
// Short events at the bottom edge stay inside the grid.
const baseTop = Math.max(0, Math.min(
((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
((clip.startMinutes - hours.startMinutes) / 60) * hourHeight,
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
@@ -708,6 +726,8 @@ export function CalendarWeekView({
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable={editable}
continuesBefore={clip.clippedStart}
continuesAfter={clip.clippedEnd}
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{editable && !clip.clippedStart && (
@@ -741,7 +761,7 @@ export function CalendarWeekView({
{todayCol && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
<div
className="absolute left-0 right-0 z-20 pointer-events-none"
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
style={{ top: ((nowMinutes - hours.startMinutes) / 60) * hourHeight }}
>
<div className="flex items-center -translate-y-1/2">
<div className="w-3 h-3 rounded-full bg-destructive -ms-1.5 flex-shrink-0" />
@@ -762,8 +782,8 @@ export function CalendarWeekView({
<div
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
style={{
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
top: ((dragCreate.startMinutes - hours.startMinutes) / 60) * hourHeight,
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * hourHeight,
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
@@ -775,7 +795,7 @@ export function CalendarWeekView({
{dropTarget?.dayKey === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * HOUR_HEIGHT }}
style={{ top: ((dropTarget.minutes - hours.startMinutes) / 60) * hourHeight }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
@@ -804,8 +824,8 @@ export function CalendarWeekView({
data-pending-preview
className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md"
style={{
top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT),
top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight,
height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight),
backgroundColor: color,
color: readableTextOn(color),
}}
+18 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format } from "date-fns";
import { Users } from "@/components/icons";
import { ChevronDown, ChevronUp, Users } from "@/components/icons";
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
@@ -22,7 +22,12 @@ interface EventCardProps {
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
isSelected?: boolean;
draggable?: boolean;
/**
* The event goes on before what is drawn: before the first day of a span,
* above the first visible hour of a block.
*/
continuesBefore?: boolean;
/** The event goes on after what is drawn: past the last day or hour. */
continuesAfter?: boolean;
className?: string;
style?: CSSProperties;
@@ -234,14 +239,25 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
data-continues-before={continuesBefore || undefined}
data-continues-after={continuesAfter || undefined}
className={cn(
"w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
// A block box: an inline one hangs its line's descent below the
// grid's last hour and makes the filled pane scroll.
"relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
// Cut at the edge of the visible hours: a square edge and an arrow
// say the event runs on past it.
continuesBefore && "rounded-t-none",
continuesAfter && "rounded-b-none",
continuesBefore && "pe-4",
isBeingDragged && "opacity-50",
className
)}
style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected }), ...style }}
>
{continuesBefore && <ChevronUp aria-hidden="true" className="absolute top-0.5 end-0.5 w-3 h-3 opacity-85" />}
{continuesAfter && <ChevronDown aria-hidden="true" className="absolute bottom-0.5 end-0.5 w-3 h-3 opacity-85" />}
{/* Shorter blocks have no room for a second line at the 60px hour, so
their start time follows the title on the same line, when the
block is wide enough to keep the title readable. */}
@@ -88,6 +88,18 @@ export function QuickCreatePopover({
return () => cancelAnimationFrame(frame);
}, [startKey, endKey]);
// Focus goes back where it came from (the slot a keyboard user created
// from) when the popover closes, unless something else has taken it.
useEffect(() => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const popover = ref.current;
return () => {
const active = document.activeElement;
const lost = !active || active === document.body || (popover?.contains(active) ?? false);
if (lost && opener?.isConnected) opener.focus({ preventScroll: true });
};
}, []);
// Focus once placed. Chrome sometimes refuses the first focus() right
// after the slot click that opened the popover, so try again briefly.
const placed = position !== null;
+2 -2
View File
@@ -202,7 +202,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Set when the app itself goes Back (to the email it came from): the
// guard's history entry is in the way then, and is stepped over.
const leavingRef = useRef(false);
const backGuard = useCloseOnBack(() => {
const backGuard = useCloseOnBack({ active: editorDirty, onBack: () => {
if (leavingRef.current) {
leavingRef.current = false;
window.history.back();
@@ -212,7 +212,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
if (discard) handleCancelRef.current();
else backGuard.rearm();
});
}, editorDirty);
} });
const routerBack = useCallback(() => {
if (backGuard.armed()) leavingRef.current = true;
router.back();
@@ -88,7 +88,7 @@ describe('FilePreviewModal history lifecycle (modeled queued traversal)', () =>
const { onClose } = openPreview();
await advance(0);
expect(window.history.state).toMatchObject(originalState);
expect(window.history.state.__filePreview).toBeTruthy();
expect(window.history.state.__closeOnBack).toBeTruthy();
clickClose();
expect(onClose).toHaveBeenCalledOnce();
expect(screen.queryByRole('dialog')).toBeNull();
+1 -1
View File
@@ -183,7 +183,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
}, []);
// Back closes the preview instead of leaving Files.
useCloseOnBack(onClose);
useCloseOnBack({ onBack: onClose });
const fileType = resolvedFileType;
+1 -1
View File
@@ -27,7 +27,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
const [error, setError] = useState(false);
// Back closes the preview instead of leaving Files, as for every other preview.
useCloseOnBack(onClose);
useCloseOnBack({ onBack: onClose });
useEffect(() => {
let revoke: string | null = null;
+1 -1
View File
@@ -41,7 +41,7 @@ interface LaunchData {
*/
export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = false }: WopiEditorProps) {
const t = useTranslations("files");
useCloseOnBack(onClose, closeOnBack);
useCloseOnBack({ onBack: onClose, active: closeOnBack });
const locale = useLocale();
const [launch, setLaunch] = useState<LaunchData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -22,5 +22,6 @@ describe('client message loader map', () => {
const messages = await LOCALE_LOADERS[locale]();
expect(Object.keys(messages).length, `locale "${locale}" has empty messages`).toBeGreaterThan(0);
}
});
// Importing all 29 catalogs one after another takes ~5 s on a busy machine.
}, 30_000);
});
@@ -0,0 +1,76 @@
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useCalendarEventSearch } from '../use-calendar-event-search';
import type { CalendarEvent } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
vi.mock('@/lib/timezone', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/timezone')>()),
displayNow: () => new Date(2026, 9, 28, 12, 0),
}));
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;
}
function clientWith(query: () => Promise<CalendarEvent[]>) {
const queryAllCalendarEvents = vi.fn(query);
return { client: { queryAllCalendarEvents } as unknown as IJMAPClient, queryAllCalendarEvents };
}
const loaded = [
makeEvent('standup-1', 'Standup', '2026-10-27T09:00:00', { uid: 'standup' }),
makeEvent('standup-2', 'Standup', '2026-10-29T09:00:00', { uid: 'standup' }),
makeEvent('standup-3', 'Standup', '2026-10-30T09:00:00', { uid: 'standup' }),
makeEvent('review', 'Design review', '2026-10-20T14:00:00', {
participants: { p: { '@type': 'Participant', name: 'Grizzelda Pentworth', email: 'griz@example.net' } } as never,
}),
];
describe('useCalendarEventSearch (CAL-16)', () => {
it('answers from the loaded events at once, participants included, one row per series', async () => {
const { client } = clientWith(() => new Promise(() => {}));
const { result } = renderHook(() => useCalendarEventSearch('griz', client, loaded));
expect(result.current?.past.map((e) => e.id)).toEqual(['review']);
expect(result.current?.searching).toBe(true);
const series = renderHook(() => useCalendarEventSearch('standup', client, loaded));
// The next occurrence to come, not every loaded one
expect(series.result.current?.upcoming.map((e) => e.id)).toEqual(['standup-2']);
expect(series.result.current?.past).toEqual([]);
});
it('adds what the server finds outside the loaded weeks, upcoming soonest first and past latest first', async () => {
const { client, queryAllCalendarEvents } = clientWith(async () => [
makeEvent('old', 'Standup retro', '2025-03-01T10:00:00'),
makeEvent('older', 'Standup kickoff', '2024-01-10T10:00:00'),
makeEvent('later', 'Standup offsite', '2027-02-01T10:00:00'),
// the series the loaded weeks already show
makeEvent('standup-master', 'Standup', '2026-01-05T09:00:00', { uid: 'standup' }),
]);
const { result } = renderHook(() => useCalendarEventSearch('standup', client, loaded));
await waitFor(() => expect(result.current?.searching).toBe(false));
expect(queryAllCalendarEvents).toHaveBeenCalledWith(
{ text: 'standup' }, [{ property: 'start', isAscending: false }], 100, expect.anything(),
);
expect(result.current?.upcoming.map((e) => e.id)).toEqual(['standup-2', 'later']);
expect(result.current?.past.map((e) => e.id)).toEqual(['old', 'older']);
});
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));
await waitFor(() => expect(result.current?.searching).toBe(false));
expect(result.current?.past.map((e) => e.id)).toEqual(['review']);
});
it('does nothing for an empty query', () => {
const { client, queryAllCalendarEvents } = clientWith(async () => []);
const { result } = renderHook(() => useCalendarEventSearch(' ', client, loaded));
expect(result.current).toBeNull();
expect(queryAllCalendarEvents).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,84 @@
import { StrictMode, type ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useCloseOnBack } from '../use-close-on-back';
const entry = () => (window.history.state as Record<string, unknown> | null)?.__closeOnBack;
async function openOverlay(options: Partial<Parameters<typeof useCloseOnBack>[0]> = {}, strict = false) {
const before = entry();
const onBack = vi.fn();
const view = renderHook((props: { active: boolean }) => useCloseOnBack({ onBack, ...options, active: props.active }), {
initialProps: { active: true },
wrapper: strict ? ({ children }: { children: ReactNode }) => <StrictMode>{children}</StrictMode> : undefined,
});
await waitFor(() => expect(entry()).toBeTruthy());
await waitFor(() => expect(entry()).not.toBe(before));
return { ...view, onBack };
}
describe('useCloseOnBack (R5)', () => {
it('calls onBack when Back leaves the overlay entry', async () => {
const { onBack, unmount } = await openOverlay();
window.history.back();
await waitFor(() => expect(onBack).toHaveBeenCalledTimes(1));
expect(entry()).toBeUndefined();
unmount();
});
it('puts the entry back while the overlay holds', async () => {
const { onBack, unmount } = await openOverlay({ shouldHold: () => true });
window.history.back();
await waitFor(() => expect(onBack).toHaveBeenCalledTimes(1));
expect(entry()).toBeTruthy();
unmount();
await waitFor(() => expect(entry()).toBeUndefined());
});
it('removes its entry when the overlay closes another way, keeping the latest URL', async () => {
const { rerender } = await openOverlay();
// The page rewrites the URL while the overlay is open
window.history.replaceState(window.history.state, '', '/calendar/week/2026-09-14');
rerender({ active: false });
await waitFor(() => expect(entry()).toBeUndefined());
await waitFor(() => expect(window.location.pathname).toBe('/calendar/week/2026-09-14'));
});
it('does not undo a navigation that pushed its own entry', async () => {
const { unmount } = await openOverlay();
// The router pushes in the same commit that unmounts the overlay
unmount();
window.history.pushState({ other: true }, '', '/mail');
await new Promise((resolve) => setTimeout(resolve, 50));
expect(window.history.state).toEqual({ other: true });
expect(window.location.pathname).toBe('/mail');
});
it('holds a new entry on rearm after onBack chose to stay open, and says whether it is armed', async () => {
const { result, onBack, unmount } = await openOverlay();
expect(result.current.armed()).toBe(true);
window.history.back();
await waitFor(() => expect(onBack).toHaveBeenCalledTimes(1));
expect(result.current.armed()).toBe(false);
expect(entry()).toBeUndefined();
result.current.rearm();
expect(result.current.armed()).toBe(true);
expect(entry()).toBeTruthy();
// ...and the next Back reaches onBack again
window.history.back();
await waitFor(() => expect(onBack).toHaveBeenCalledTimes(2));
unmount();
});
it('pushes one entry and no Back under Strict Mode', async () => {
const push = vi.spyOn(window.history, 'pushState');
const back = vi.spyOn(window.history, 'back');
const { unmount } = await openOverlay({}, true);
expect(push).toHaveBeenCalledTimes(1);
expect(back).not.toHaveBeenCalled();
unmount();
await waitFor(() => expect(entry()).toBeUndefined());
push.mockRestore();
back.mockRestore();
});
});
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { matchesTerms, parseSearchQuery } from "@/lib/global-search/query-parser";
import { calendarFilterFor, eventFields } from "@/lib/global-search/providers/calendar";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import { debug } from "@/lib/debug";
import type { CalendarEvent } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
const SERVER_LIMIT = 100;
const DEBOUNCE_MS = 250;
export interface CalendarSearchResults {
/** Events still to come (or going on), soonest first. */
upcoming: CalendarEvent[];
/** Events that are over, latest first. */
past: CalendarEvent[];
/** Waiting for the server's answer. */
searching: boolean;
}
// 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}`;
/**
* Searches the user's events by title, place, description and participants
* (CAL-16). The events already loaded answer at once; the server's
* CalendarEvent/query `text` filter then adds the rest of the calendar. A
* server that can't answer leaves the loaded matches.
*/
export function useCalendarEventSearch(
query: string,
client: IJMAPClient | null,
loadedEvents: CalendarEvent[],
): CalendarSearchResults | null {
const parsed = useMemo(() => parseSearchQuery(query), [query]);
const active = parsed.terms.length > 0;
const text = parsed.text;
const local = useMemo(
() => (active ? loadedEvents.filter((event) => matchesTerms(parsed.terms, eventFields(event))) : []),
[active, loadedEvents, parsed],
);
const [remote, setRemote] = useState<{ text: string; events: CalendarEvent[] | null } | null>(null);
useEffect(() => {
if (!active || !client) return;
const controller = new AbortController();
const timer = setTimeout(() => {
// Inside the chain, so a client that can't query at all lands in catch.
Promise.resolve().then(() => client.queryAllCalendarEvents(
calendarFilterFor(parsed),
[{ property: "start", isAscending: false }],
SERVER_LIMIT,
{ signal: controller.signal },
)).then((events) => {
if (!controller.signal.aborted) setRemote({ text, events });
}).catch((error: unknown) => {
if (controller.signal.aborted) return;
debug.warn("calendar", "Event search on the server failed; showing loaded events only", error);
setRemote({ text, events: null });
});
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [active, client, parsed, text]);
return useMemo(() => {
if (!active) return null;
const answered = remote?.text === text ? remote : null;
const now = displayNow().getTime();
const ends = (event: CalendarEvent) => getEventEndDate(event).getTime();
const starts = (event: CalendarEvent) => getEventStartDate(event).getTime();
// Of a series' loaded occurrences, the next one to come, else the last.
const bySeries = new Map<string, CalendarEvent>();
for (const event of local) {
const key = seriesKey(event);
const kept = bySeries.get(key);
if (!kept) { bySeries.set(key, event); continue; }
const keptUpcoming = ends(kept) >= now;
const upcoming = ends(event) >= now;
if (upcoming && (!keptUpcoming || starts(event) < starts(kept))) bySeries.set(key, event);
else if (!upcoming && !keptUpcoming && starts(event) > starts(kept)) bySeries.set(key, event);
}
for (const event of answered?.events ?? []) {
if (typeof event.start !== "string" || !event.start) continue;
const key = seriesKey(event);
if (!bySeries.has(key)) bySeries.set(key, event);
}
const upcoming: CalendarEvent[] = [];
const past: CalendarEvent[] = [];
for (const event of bySeries.values()) {
if (Number.isNaN(starts(event))) continue;
(ends(event) >= now ? upcoming : past).push(event);
}
upcoming.sort((a, b) => starts(a) - starts(b));
past.sort((a, b) => starts(b) - starts(a));
return { upcoming, past, searching: !!client && !answered };
}, [active, local, remote, text, client]);
}
+58 -24
View File
@@ -3,50 +3,77 @@
import { useEffect, useRef, useState } from "react";
import { generateUUID } from "@/lib/utils";
const STATE_KEY = "__closeOnBack";
// Closing one layer can leave an asynchronous Back traversal in flight. The
// next layer must not install its popstate listener until that completes, or
// the traversal would close it at once.
// next layer must not push its entry until that completes, or the traversal
// would pop it and close the layer at once.
let pendingHistoryBack: Promise<void> | null = null;
const currentEntry = () => (window.history.state as Record<string, unknown> | null)?.[STATE_KEY];
interface UseCloseOnBackOptions {
/** Called when browser Back (or the phone's back gesture) leaves the entry. */
onBack: () => void;
/** Whether the layer is open: it holds a history entry while it is. */
active?: boolean;
/**
* Whether Back should stay on the layer for now, e.g. an editor with
* unsaved changes that asks first. Checked as Back lands: the entry is put
* back at once, so another Back while `onBack` asks is caught too, and
* `onBack` decides whether to close.
*/
shouldHold?: () => boolean;
}
export interface CloseOnBack {
/** Holds an entry again after `onBack` decided to stay open. */
rearm: () => void;
/** Whether the entry is there now, e.g. before a `router.back()` that must leave for real. */
armed: () => boolean;
}
/**
* Lets the browser's Back (and the phone's back gesture) close an overlay
* instead of leaving the page: while `active`, the overlay holds a history
* entry of its own, and Back calls `onBack`. Closed any other way, the entry
* is taken back off, so the history ends where it was.
*
* `onBack` runs after the entry is gone. A layer that decides to stay open
* (an editor that asked "Discard changes?" and heard "Keep editing") calls
* the returned `rearm` to hold an entry again. `armed` tells whether the
* entry is there now, e.g. before a `router.back()` that must leave for real.
* Lets the browser's Back close an overlay, panel or editor instead of
* leaving the page (R5). While `active`, the layer holds a history entry of
* its own and Back calls `onBack`. Closed any other way, the entry is taken
* back off, so no stray steps are left for Back to walk through:
* - not when a navigation in the same commit (leaving through the rail)
* has pushed its own entry by then, which must not be undone;
* - and once the clean-up Back has landed, the address bar shows the URL
* the page wrote for the closed state, not the older one of the entry
* below.
* The entry copies the current state, Next's router state included.
*/
export function useCloseOnBack(onBack: () => void, active = true): { rearm: () => void; armed: () => boolean } {
export function useCloseOnBack({ onBack, active = true, shouldHold }: UseCloseOnBackOptions): CloseOnBack {
const onBackRef = useRef(onBack);
onBackRef.current = onBack;
const shouldHoldRef = useRef(shouldHold);
shouldHoldRef.current = shouldHold;
const rearmRef = useRef<() => void>(() => {});
const armedRef = useRef(false);
useEffect(() => {
if (!active) return;
if (!active || typeof window === "undefined") return;
let cancelled = false;
let entryId: string | null = null;
let poppedByBack = false;
const onPop = () => {
if (!entryId) return;
const onPop = (event: PopStateEvent) => {
// Forward into our own entry is not a Back.
if (!entryId || (event.state as Record<string, unknown> | null)?.[STATE_KEY] === entryId) return;
entryId = null;
armedRef.current = false;
poppedByBack = true;
window.removeEventListener("popstate", onPop);
if (shouldHoldRef.current?.()) push();
onBackRef.current();
};
const push = () => {
function push() {
if (cancelled || entryId) return;
entryId = generateUUID();
armedRef.current = true;
poppedByBack = false;
window.history.pushState({ ...window.history.state, __filePreview: entryId }, "");
window.history.pushState({ ...window.history.state, [STATE_KEY]: entryId }, "");
window.addEventListener("popstate", onPop);
};
}
rearmRef.current = push;
// Deferred, so Strict Mode's setup/cleanup/setup replay can cancel the
@@ -63,19 +90,26 @@ export function useCloseOnBack(onBack: () => void, active = true): { rearm: () =
window.clearTimeout(timer);
window.removeEventListener("popstate", onPop);
const ours = entryId;
if (ours && !poppedByBack && window.history.state?.__filePreview === ours) {
if (!ours || currentEntry() !== ours) return;
// After this commit's effects: by then a navigation in the same commit
// has pushed its entry, and the page has written the URL for the
// closed state.
queueMicrotask(() => {
if (currentEntry() !== ours) return;
const url = window.location.href;
pendingHistoryBack = new Promise<void>((resolve) => {
window.addEventListener("popstate", () => {
if (window.location.href !== url) window.history.replaceState(window.history.state, "", url);
pendingHistoryBack = null;
resolve();
}, { once: true });
});
window.history.back();
}
});
};
}, [active]);
// The same object every render, so callers can list it as a dependency.
const [api] = useState(() => ({ rearm: () => rearmRef.current(), armed: () => armedRef.current }));
const [api] = useState<CloseOnBack>(() => ({ rearm: () => rearmRef.current(), armed: () => armedRef.current }));
return api;
}
+50 -10
View File
@@ -11,17 +11,33 @@ export interface RevealTime {
interface UseDisplayHoursOptions {
scrollRef: RefObject<HTMLElement | null>;
hourHeight: number;
/** The smallest height of an hour row; rows grow to fill a taller pane. */
minHourHeight: number;
/** The sticky header above the hour rows, whose height the rows don't get. */
headerRef?: RefObject<HTMLElement | null>;
revealAt?: RevealTime | null;
}
/**
* The height of an hour row: `min`, or more when the hours would leave the
* pane's bottom empty (a short range on a tall tablet). A pane only a little
* short of the hours gets slightly lower rows rather than a few pixels of
* scrolling. Whole pixels, so the hour lines stay sharp.
*/
export function fillHourHeight(min: number, available: number, hourCount: number): number {
if (hourCount <= 0 || available <= 0) return min;
const fit = Math.floor(available / hourCount);
return fit >= Math.ceil(min * 0.9) ? fit : min;
}
/**
* The hours a time grid draws (#1164): the configured range, or the whole
* day while the user has asked to see it. Owns the grid's vertical scroll
* position too - it opens an hour before now, and keeps the same hours in
* place when the range changes underneath it.
* place when the range or the row height changes underneath it. Rows fill
* the pane when the hours are fewer than it has room for.
*/
export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayHoursOptions) {
export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt }: UseDisplayHoursOptions) {
const limitHours = useSettingsStore((s) => s.calendarLimitHours);
const startHour = useSettingsStore((s) => s.calendarDayStartHour);
const endHour = useSettingsStore((s) => s.calendarDayEndHour);
@@ -31,7 +47,27 @@ export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayH
);
const [showAllHours, setShowAllHours] = useState(false);
const hours: DisplayHours = showAllHours ? FULL_DAY_HOURS : configured;
const hourCount = (hours.endMinutes - hours.startMinutes) / 60;
// The room below the header, measured whenever the pane or the header
// (the all-day row grows with its events) changes size.
const [available, setAvailable] = useState(0);
useLayoutEffect(() => {
const root = scrollRef.current;
if (!root) return;
const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0)));
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(root);
if (headerRef?.current) observer.observe(headerRef.current);
return () => observer.disconnect();
}, [scrollRef, headerRef]);
const hourHeight = fillHourHeight(minHourHeight, available, hourCount);
// Opens an hour before now, once. Rows of any other height than the
// minimum fit the pane without scrolling, so the minimum is the height
// that matters here.
const initialHoursRef = useRef(hours);
useEffect(() => {
const root = scrollRef.current;
@@ -45,22 +81,24 @@ export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayH
root.scrollTop = 0;
return;
}
root.scrollTop = Math.max(0, ((now.getHours() - 1) * 60 - startMinutes) / 60 * hourHeight);
}, [scrollRef, hourHeight]);
root.scrollTop = Math.max(0, ((now.getHours() - 1) * 60 - startMinutes) / 60 * minHourHeight);
}, [scrollRef, minHourHeight]);
const revealRef = useRef<number | null>(null);
const renderedStartRef = useRef(hours.startMinutes);
const renderedRef = useRef({ startMinutes: hours.startMinutes, hourHeight });
useLayoutEffect(() => {
const root = scrollRef.current;
const prev = renderedStartRef.current;
renderedStartRef.current = hours.startMinutes;
const prev = renderedRef.current;
renderedRef.current = { startMinutes: hours.startMinutes, hourHeight };
const reveal = revealRef.current;
revealRef.current = null;
if (!root) return;
if (reveal !== null) {
root.scrollTop = Math.max(0, ((reveal - hours.startMinutes) / 60 - 0.5) * hourHeight);
} else if (prev !== hours.startMinutes) {
root.scrollTop = Math.max(0, root.scrollTop + ((prev - hours.startMinutes) / 60) * hourHeight);
} else if (prev.startMinutes !== hours.startMinutes || prev.hourHeight !== hourHeight) {
// The time at the top of the pane stays there.
const topMinutes = prev.startMinutes + (root.scrollTop / prev.hourHeight) * 60;
root.scrollTop = Math.max(0, ((topMinutes - hours.startMinutes) / 60) * hourHeight);
}
}, [scrollRef, hourHeight, hours.startMinutes]);
@@ -93,6 +131,8 @@ export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayH
return {
hours,
/** The drawn height of an hour row, in pixels. */
hourHeight,
/** Whether a range is configured at all, i.e. the toggle has anything to do. */
canToggle: configured.restricted,
configured,
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useMemo, useState, type KeyboardEvent, type RefObject } from "react";
import { dayKey } from "@/lib/calendar-scroll-window";
import { calendarShortcutAction } from "@/lib/calendar-shortcuts";
import { displayNow } from "@/lib/timezone";
import type { DisplayHours } from "@/lib/calendar-display-range";
interface UseTimeGridFocusOptions {
/** The grid's scroll container. */
rootRef: RefObject<HTMLElement | null>;
/** The sticky header, which must not cover the focused slot. */
headerRef: RefObject<HTMLElement | null>;
/** Width of the sticky hour gutter at the inline start. */
gutterWidth: number;
days: Date[];
hours: DisplayHours;
/** The day the tab stop starts on while no slot has been focused yet. */
selectedDate: Date;
/** Creates an event at the slot (Enter, `n` or `c`). */
onCreate: (start: Date) => void;
}
export interface TimeGridSlotProps {
"data-slot": string;
tabIndex: number;
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void;
onFocus: () => void;
}
const slotId = (key: string, hour: number) => `${key}T${hour}`;
/**
* Keyboard access to the empty slots of the week and day grids (CAL-13,
* R8): one tab stop for the whole grid, the arrows move by an hour and a
* day, Home and End go to the first and last hour, Enter or `n` creates an
* event there. The month grid works the same way with days.
*/
export function useTimeGridFocus({
rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate,
}: UseTimeGridFocusOptions) {
const firstHour = hours.startMinutes / 60;
const lastHour = hours.endMinutes / 60 - 1;
const [focused, setFocused] = useState<{ key: string; hour: number } | null>(null);
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.
const tabStop = useMemo(() => {
if (focused && dayIndexByKey.has(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()));
return { key, hour };
}, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days]);
const focusSlot = useCallback((key: string, hour: number) => {
const root = rootRef.current;
const el = root?.querySelector<HTMLElement>(`[data-slot="${slotId(key, hour)}"]`);
if (!el) return;
setFocused({ key, hour });
// Clear of the sticky header and hour gutter.
el.style.scrollMarginTop = `${headerRef.current?.offsetHeight ?? 0}px`;
el.style.scrollMarginInlineStart = `${gutterWidth}px`;
el.focus({ preventScroll: true });
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}, [rootRef, headerRef, gutterWidth]);
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLElement>, day: Date, hour: number) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const key = dayKey(day);
const index = dayIndexByKey.get(key) ?? 0;
if (e.key === "Enter" || calendarShortcutAction(e.nativeEvent) === "new") {
// The slot's own create, not the calendar's `n` at the selected day.
e.preventDefault();
const start = new Date(day);
start.setHours(hour, 0, 0, 0);
onCreate(start);
return;
}
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
let nextIndex = index;
let nextHour = hour;
switch (e.key) {
case "ArrowUp": nextHour = hour - 1; break;
case "ArrowDown": nextHour = hour + 1; break;
case "ArrowLeft": nextIndex = index + (rtl ? 1 : -1); break;
case "ArrowRight": nextIndex = index + (rtl ? -1 : 1); break;
case "Home": nextHour = firstHour; break;
case "End": nextHour = lastHour; break;
default: return;
}
// The grid's arrows, not the calendar's previous/next period.
e.preventDefault();
const nextDay = days[nextIndex];
if (!nextDay || nextHour < firstHour || nextHour > lastHour) return;
focusSlot(dayKey(nextDay), nextHour);
}, [dayIndexByKey, days, firstHour, lastHour, focusSlot, onCreate]);
/** Props for the slot of `day` at `hour`; pass `enabled: false` on phones. */
const slotProps = useCallback((day: Date, hour: number, enabled = true): TimeGridSlotProps => {
const key = dayKey(day);
return {
"data-slot": slotId(key, hour),
tabIndex: enabled && key === tabStop.key && hour === tabStop.hour ? 0 : -1,
onKeyDown: (e) => handleKeyDown(e, day, hour),
onFocus: () => setFocused((prev) => (prev?.key === key && prev.hour === hour ? prev : { key, hour })),
};
}, [tabStop, handleKeyDown]);
/** The day of the tab stop: the header's date buttons take Tab on that day only. */
return { slotProps, tabStopDayKey: tabStop.key };
}
+3
View File
@@ -32,6 +32,9 @@ describe('Calendar keyboard shortcuts', () => {
}
expect(calendarShortcutAction(pressed('t', 'KeyT'))).toBe('today');
expect(calendarShortcutAction(pressed('n', 'KeyN'))).toBe('new');
expect(calendarShortcutAction(pressed('/', 'Slash'))).toBe('search');
// Shift + / is ?, the shell's help
expect(calendarShortcutAction({ ...pressed('?', 'Slash'), shiftKey: true })).toBeNull();
});
it('ignores letters held with a modifier', () => {
+2 -2
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
describe('runBatchEmailAction', () => {
it('reports success when the action ran', async () => {
await runBatchEmailAction(async () => true, messages);
expect(toast.success).toHaveBeenCalledWith('3 messages archived');
expect(toast.success).toHaveBeenCalledWith('3 messages archived', undefined);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -41,6 +41,6 @@ describe('runBatchEmailAction', () => {
useEmailStore.setState({ error: 'Failed to load the folder' });
await runBatchEmailAction(async () => true, messages);
expect(toast.error).not.toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('3 messages archived');
expect(toast.success).toHaveBeenCalledWith('3 messages archived', undefined);
});
});
+5 -3
View File
@@ -49,13 +49,15 @@ describe('plugin UI kit components', () => {
// copy must still be there, word for word.
const copies: [string, string][] = [
['components/settings/settings-section.tsx', 'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150'],
['components/settings/settings-section.tsx', 'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150'],
['components/settings/settings-section.tsx', 'inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-150'],
['components/settings/settings-section.tsx', 'inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150'],
['components/settings/settings-section.tsx', 'bg-muted ring-1 ring-inset ring-muted-foreground'],
['components/settings/settings-section.tsx', 'inline-block h-4 w-4 transform rounded-full transition-transform duration-150'],
['components/settings/settings-section.tsx', 'bg-muted-foreground ltr:translate-x-1 rtl:-translate-x-1'],
['components/settings/settings-section.tsx', 'flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0'],
['components/settings/settings-section.tsx', 'px-3 py-1.5 text-xs rounded-md transition-colors duration-150'],
['components/email/composer-toolbar-menu.tsx', 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border'],
['components/calendar/ical-import-modal.tsx', 'flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors'],
['components/files/file-empty-state.tsx', 'w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-4'],
['components/ui/empty-state.tsx', 'w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-4'],
['components/files/file-views.tsx', 'border-b border-border/60'],
['components/files/files-header.tsx', 'px-1.5 py-1 rounded-md transition-colors truncate max-w-[16rem]'],
['components/email/email-viewer.tsx', 'w-10 h-10 rounded-full'],
+6 -2
View File
@@ -4,6 +4,11 @@ import { shortcutAlternatives } from '@/lib/shortcut-keys';
// The shortcuts help shows " / " as "or" between whole keys. "Shift + ↓ / ↑"
// read as "Shift + ↓ or ↑", which says plain ↑ also extends the selection.
// Unrelated keys may mix ("F5 / Mod + R", "/ / Mod + F"); arrows may not.
const ARROWS = ['↑', '↓', '←', '→'];
const isArrow = (key: string) => ARROWS.includes(key);
const endsInArrow = (key: string) => ARROWS.some((arrow) => key.endsWith(` + ${arrow}`));
describe('shortcut alternatives', () => {
it('spell out the modifier in every alternative', () => {
@@ -12,8 +17,7 @@ describe('shortcut alternatives', () => {
for (const section of app.sections) {
for (const entry of section.entries) {
const alternatives = shortcutAlternatives(entry.key);
const modified = alternatives.some((key) => key.includes(' + '));
if (modified && alternatives.some((key) => !key.includes(' + '))) bad.push(`${app.id}: ${entry.key}`);
if (alternatives.some(endsInArrow) && alternatives.some(isArrow)) bad.push(`${app.id}: ${entry.key}`);
}
}
}
+4 -1
View File
@@ -11,7 +11,8 @@ export type CalendarShortcutAction =
| "day"
| "agenda"
| "tasks"
| "new";
| "new"
| "search";
const LETTERS: Record<string, CalendarShortcutAction> = {
t: "today",
@@ -31,6 +32,8 @@ export function calendarShortcutAction(
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
if (event.key === "ArrowLeft") return "prev";
if (event.key === "ArrowRight") return "next";
// `/` focuses the search, as in Mail (R8); on any layout, by its key.
if (event.code === "Slash" || (!event.code && event.key === "/")) return "search";
const code = event.code;
const letter = code && code.length === 4 && code.startsWith("Key")
? code.charAt(3).toLowerCase()
+2 -1
View File
@@ -5,7 +5,8 @@ import { textConditions } from '@/lib/jmap/search-utils';
import type { Calendar, CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
function eventFields(event: CalendarEvent): string[] {
/** The text an event is found by: title, description, places and participants. */
export function eventFields(event: CalendarEvent): string[] {
const fields: string[] = [event.title, event.description];
for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name);
for (const participant of Object.values(event.participants ?? {})) {
+2
View File
@@ -28,6 +28,8 @@ export const CALENDAR_SECTIONS: readonly ShortcutSection[] = [
id: "actions",
title: "calendar.shortcuts.actions",
entries: [
{ key: "↑ ↓ ← →", description: "calendar.shortcuts.move_in_grid" },
{ key: "/", description: "calendar.search.title" },
{ key: "n / c", description: "calendar.shortcuts.new_item" },
{ key: "e", description: "calendar.events.edit" },
{ key: "Mod + Enter", description: "calendar.form.save" },
+1 -1
View File
@@ -8,7 +8,7 @@ export const CONTACTS_SECTIONS: readonly ShortcutSection[] = [
entries: [
{ key: "↓ / ↑", description: "contacts.shortcuts.move" },
{ key: "Home / End", description: "contacts.shortcuts.first_last" },
{ key: "Shift + ↓ / ↑", description: "contacts.shortcuts.extend" },
{ key: "Shift + ↓ / Shift + ↑", description: "contacts.shortcuts.extend" },
{ key: "Enter", description: "contacts.shortcuts.open" },
{ key: "Space", description: "contacts.shortcuts.toggle" },
{ key: "Mod + A", description: "contacts.shortcuts.select_all" },
+1 -1
View File
@@ -9,7 +9,7 @@ export const FILES_SECTIONS: readonly ShortcutSection[] = [
{ key: "↓ / ↑", description: "files.shortcuts.move" },
{ key: "← / →", description: "files.shortcuts.move_grid" },
{ key: "Home / End", description: "files.shortcuts.first_last" },
{ key: "Shift + ↓ / ↑", description: "files.shortcuts.extend" },
{ key: "Shift + ↓ / Shift + ↑", description: "files.shortcuts.extend" },
{ key: "Enter", description: "files.shortcuts.open" },
{ key: "Space", description: "files.shortcuts.toggle" },
{ key: "Mod + A", description: "files.shortcuts.select_all" },
+3 -3
View File
@@ -710,14 +710,14 @@ export function Switch({ checked, onChange, disabled = false, label, id }: { che
onClick={() => onChange?.(!checked)}
className={cn(
'bk relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
checked ? 'bg-primary' : 'bg-muted',
checked ? 'bg-primary' : 'bg-muted ring-1 ring-inset ring-muted-foreground',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-150',
checked ? 'ltr:translate-x-6 rtl:-translate-x-6' : 'ltr:translate-x-1 rtl:-translate-x-1',
'inline-block h-4 w-4 transform rounded-full transition-transform duration-150',
checked ? 'bg-background ltr:translate-x-6 rtl:-translate-x-6' : 'bg-muted-foreground ltr:translate-x-1 rtl:-translate-x-1',
)}
/>
</button>
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "الإجراءات",
"previous": "اليوم أو الأسبوع أو الشهر السابق",
"next": "اليوم أو الأسبوع أو الشهر التالي",
"new_item": "حدث جديد، أو مهمة جديدة في المهام"
"new_item": "حدث جديد، أو مهمة جديدة في المهام",
"move_in_grid": "التنقل بين الأيام والساعات في الشبكة"
},
"other_calendars": "تقويمات أخرى",
"calendar_options": "خيارات {name}"
"calendar_options": "خيارات {name}",
"search": {
"title": "البحث في الأحداث",
"clear": "مسح البحث",
"close": "إغلاق البحث",
"upcoming": "القادمة",
"past": "السابقة",
"no_results": "لا توجد أحداث تطابق «{query}»",
"no_results_hint": "يبحث في العناوين والأماكن والأوصاف والضيوف.",
"repeats": "متكرر",
"results": "{count, plural, zero {لم يُعثر على أي حدث} one {تم العثور على حدث واحد} two {تم العثور على حدثين} few {تم العثور على # أحداث} many {تم العثور على # حدثًا} other {تم العثور على # حدث}}"
}
},
"calendar_event_notifications": {
"someone": "شخص ما",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Accions",
"previous": "Dia, setmana o mes anterior",
"next": "Dia, setmana o mes següent",
"new_item": "Esdeveniment nou, o una tasca nova a Tasques"
"new_item": "Esdeveniment nou, o una tasca nova a Tasques",
"move_in_grid": "Moure's entre dies i hores a la quadrícula"
},
"other_calendars": "Altres calendaris",
"calendar_options": "Opcions de {name}"
"calendar_options": "Opcions de {name}",
"search": {
"title": "Cerca esdeveniments",
"clear": "Esborra la cerca",
"close": "Tanca la cerca",
"upcoming": "Propers",
"past": "Passats",
"no_results": "Cap esdeveniment coincideix amb «{query}»",
"no_results_hint": "La cerca mira títols, llocs, descripcions i convidats.",
"repeats": "Es repeteix",
"results": "{count, plural, one {# esdeveniment trobat} other {# esdeveniments trobats}}"
}
},
"calendar_event_notifications": {
"someone": "Algú",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Akce",
"previous": "Předchozí den, týden nebo měsíc",
"next": "Další den, týden nebo měsíc",
"new_item": "Nová událost, v Úkolech nový úkol"
"new_item": "Nová událost, v Úkolech nový úkol",
"move_in_grid": "Přesun mezi dny a hodinami v mřížce"
},
"other_calendars": "Další kalendáře",
"calendar_options": "Možnosti pro {name}"
"calendar_options": "Možnosti pro {name}",
"search": {
"title": "Hledat události",
"clear": "Vymazat hledání",
"close": "Zavřít hledání",
"upcoming": "Nadcházející",
"past": "Minulé",
"no_results": "Hledanému „{query}“ neodpovídá žádná událost",
"no_results_hint": "Hledá se v názvech, místech, popisech a hostech.",
"repeats": "Opakuje se",
"results": "{count, plural, one {Nalezena # událost} few {Nalezeny # události} many {Nalezeno # události} other {Nalezeno # událostí}}"
}
},
"advanced_search": {
"title": "Pokročilé hledání",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Handlinger",
"previous": "Forrige dag, uge eller måned",
"next": "Næste dag, uge eller måned",
"new_item": "Ny begivenhed, eller en ny opgave i Opgaver"
"new_item": "Ny begivenhed, eller en ny opgave i Opgaver",
"move_in_grid": "Gå mellem dage og timer i gitteret"
},
"other_calendars": "Andre kalendere",
"calendar_options": "Indstillinger for {name}"
"calendar_options": "Indstillinger for {name}",
"search": {
"title": "Søg i begivenheder",
"clear": "Ryd søgning",
"close": "Luk søgning",
"upcoming": "Kommende",
"past": "Tidligere",
"no_results": "Ingen begivenheder matcher “{query}”",
"no_results_hint": "Søgningen ser på titler, steder, beskrivelser og gæster.",
"repeats": "Gentages",
"results": "{count, plural, one {# begivenhed fundet} other {# begivenheder fundet}}"
}
},
"calendar_event_notifications": {
"someone": "Nogen",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Aktionen",
"previous": "Zurück um einen Tag, eine Woche oder einen Monat",
"next": "Weiter um einen Tag, eine Woche oder einen Monat",
"new_item": "Neuer Termin, in Aufgaben eine neue Aufgabe"
"new_item": "Neuer Termin, in Aufgaben eine neue Aufgabe",
"move_in_grid": "Im Raster zwischen Tagen und Stunden wechseln"
},
"other_calendars": "Weitere Kalender",
"calendar_options": "Optionen für {name}"
"calendar_options": "Optionen für {name}",
"search": {
"title": "Termine durchsuchen",
"clear": "Suche löschen",
"close": "Suche schließen",
"upcoming": "Demnächst",
"past": "Vergangen",
"no_results": "Keine Termine zu „{query}“",
"no_results_hint": "Gesucht wird in Titeln, Orten, Beschreibungen und Gästen.",
"repeats": "Wiederholt sich",
"results": "{count, plural, one {# Termin gefunden} other {# Termine gefunden}}"
}
},
"advanced_search": {
"title": "Erweiterte Suche",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Actions",
"previous": "Previous day, week or month",
"next": "Next day, week or month",
"new_item": "New event, or a task in Tasks"
"new_item": "New event, or a task in Tasks",
"move_in_grid": "Move between days and hours in the grid"
},
"other_calendars": "Other calendars",
"calendar_options": "Options for {name}"
"calendar_options": "Options for {name}",
"search": {
"title": "Search events",
"clear": "Clear search",
"close": "Close search",
"upcoming": "Upcoming",
"past": "Past",
"no_results": "No events match “{query}”",
"no_results_hint": "Search looks at titles, places, descriptions and guests.",
"repeats": "Repeats",
"results": "{count, plural, one {# event found} other {# events found}}"
}
},
"calendar_event_notifications": {
"someone": "Someone",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Acciones",
"previous": "Día, semana o mes anterior",
"next": "Día, semana o mes siguiente",
"new_item": "Nuevo evento, o una tarea nueva en Tareas"
"new_item": "Nuevo evento, o una tarea nueva en Tareas",
"move_in_grid": "Moverse entre días y horas en la cuadrícula"
},
"other_calendars": "Otros calendarios",
"calendar_options": "Opciones de {name}"
"calendar_options": "Opciones de {name}",
"search": {
"title": "Buscar eventos",
"clear": "Borrar búsqueda",
"close": "Cerrar búsqueda",
"upcoming": "Próximos",
"past": "Pasados",
"no_results": "Ningún evento coincide con «{query}»",
"no_results_hint": "La búsqueda mira títulos, lugares, descripciones e invitados.",
"repeats": "Se repite",
"results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}"
}
},
"advanced_search": {
"title": "Búsqueda avanzada",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Toimingud",
"previous": "Eelmine päev, nädal või kuu",
"next": "Järgmine päev, nädal või kuu",
"new_item": "Uus sündmus, ülesannetes uus ülesanne"
"new_item": "Uus sündmus, ülesannetes uus ülesanne",
"move_in_grid": "Liigu ruudustikus päevade ja tundide vahel"
},
"other_calendars": "Muud kalendrid",
"calendar_options": "Kalendri {name} valikud"
"calendar_options": "Kalendri {name} valikud",
"search": {
"title": "Otsi sündmusi",
"clear": "Tühjenda otsing",
"close": "Sulge otsing",
"upcoming": "Tulevased",
"past": "Möödunud",
"no_results": "Päringule „{query}“ ei vasta ükski sündmus",
"no_results_hint": "Otsitakse pealkirjadest, kohtadest, kirjeldustest ja külalistest.",
"repeats": "Kordub",
"results": "{count, plural, one {Leiti # sündmus} other {Leiti # sündmust}}"
}
},
"calendar_event_notifications": {
"someone": "Keegi",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "اقدامات",
"previous": "روز، هفته یا ماه قبلی",
"next": "روز، هفته یا ماه بعدی",
"new_item": "رویداد جدید، یا وظیفه جدید در وظایف"
"new_item": "رویداد جدید، یا وظیفه جدید در وظایف",
"move_in_grid": "جابه‌جایی بین روزها و ساعت‌ها در جدول"
},
"other_calendars": "تقویم‌های دیگر",
"calendar_options": "گزینه‌های {name}"
"calendar_options": "گزینه‌های {name}",
"search": {
"title": "جستجوی رویدادها",
"clear": "پاک کردن جستجو",
"close": "بستن جستجو",
"upcoming": "پیش رو",
"past": "گذشته",
"no_results": "هیچ رویدادی با «{query}» مطابقت ندارد",
"no_results_hint": "جستجو در عنوان‌ها، مکان‌ها، توضیحات و مهمان‌ها انجام می‌شود.",
"repeats": "تکرار می‌شود",
"results": "{count, plural, one {# رویداد پیدا شد} other {# رویداد پیدا شد}}"
}
},
"calendar_event_notifications": {
"someone": "کسی",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Actions",
"previous": "Jour, semaine ou mois précédent",
"next": "Jour, semaine ou mois suivant",
"new_item": "Nouvel événement, ou une nouvelle tâche dans Tâches"
"new_item": "Nouvel événement, ou une nouvelle tâche dans Tâches",
"move_in_grid": "Se déplacer entre les jours et les heures dans la grille"
},
"other_calendars": "Autres agendas",
"calendar_options": "Options de {name}"
"calendar_options": "Options de {name}",
"search": {
"title": "Rechercher des événements",
"clear": "Effacer la recherche",
"close": "Fermer la recherche",
"upcoming": "À venir",
"past": "Passés",
"no_results": "Aucun événement ne correspond à « {query} »",
"no_results_hint": "La recherche porte sur les titres, lieux, descriptions et invités.",
"repeats": "Se répète",
"results": "{count, plural, one {# événement trouvé} other {# événements trouvés}}"
}
},
"advanced_search": {
"title": "Recherche avancée",
+14 -2
View File
@@ -4066,10 +4066,22 @@
"actions": "פעולות",
"previous": "היום, השבוע או החודש הקודם",
"next": "היום, השבוע או החודש הבא",
"new_item": "אירוע חדש, או משימה חדשה במשימות"
"new_item": "אירוע חדש, או משימה חדשה במשימות",
"move_in_grid": "מעבר בין ימים ושעות ברשת"
},
"other_calendars": "יומנים אחרים",
"calendar_options": "אפשרויות עבור {name}"
"calendar_options": "אפשרויות עבור {name}",
"search": {
"title": "חיפוש אירועים",
"clear": "ניקוי החיפוש",
"close": "סגירת החיפוש",
"upcoming": "קרובים",
"past": "קודמים",
"no_results": "אין אירועים שתואמים ל„{query}”",
"no_results_hint": "החיפוש בודק כותרות, מקומות, תיאורים ומוזמנים.",
"repeats": "חוזר",
"results": "{count, plural, one {נמצא אירוע אחד} two {נמצאו שני אירועים} other {נמצאו # אירועים}}"
}
},
"advanced_search": {
"title": "חיפוש מתקדם",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Műveletek",
"previous": "Előző nap, hét vagy hónap",
"next": "Következő nap, hét vagy hónap",
"new_item": "Új esemény, a Feladatokban új feladat"
"new_item": "Új esemény, a Feladatokban új feladat",
"move_in_grid": "Mozgás a napok és órák között a rácsban"
},
"other_calendars": "Egyéb naptárak",
"calendar_options": "{name} beállításai"
"calendar_options": "{name} beállításai",
"search": {
"title": "Események keresése",
"clear": "Keresés törlése",
"close": "Keresés bezárása",
"upcoming": "Közelgő",
"past": "Korábbi",
"no_results": "Egy esemény sem felel meg ennek: „{query}”",
"no_results_hint": "A keresés a címekben, helyszínekben, leírásokban és vendégekben keres.",
"repeats": "Ismétlődik",
"results": "{count, plural, one {# esemény található} other {# esemény található}}"
}
},
"calendar_event_notifications": {
"someone": "Valaki",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Azioni",
"previous": "Giorno, settimana o mese precedente",
"next": "Giorno, settimana o mese successivo",
"new_item": "Nuovo evento, o una nuova attività in Attività"
"new_item": "Nuovo evento, o una nuova attività in Attività",
"move_in_grid": "Spostarsi tra giorni e ore nella griglia"
},
"other_calendars": "Altri calendari",
"calendar_options": "Opzioni per {name}"
"calendar_options": "Opzioni per {name}",
"search": {
"title": "Cerca eventi",
"clear": "Cancella ricerca",
"close": "Chiudi ricerca",
"upcoming": "Prossimi",
"past": "Passati",
"no_results": "Nessun evento corrisponde a «{query}»",
"no_results_hint": "La ricerca guarda titoli, luoghi, descrizioni e invitati.",
"repeats": "Si ripete",
"results": "{count, plural, one {# evento trovato} other {# eventi trovati}}"
}
},
"advanced_search": {
"title": "Ricerca avanzata",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "操作",
"previous": "前の日・週・月",
"next": "次の日・週・月",
"new_item": "新しいイベント(タスク画面では新しいタスク)"
"new_item": "新しいイベント(タスク画面では新しいタスク)",
"move_in_grid": "グリッド内で日と時間を移動"
},
"other_calendars": "他のカレンダー",
"calendar_options": "{name} のオプション"
"calendar_options": "{name} のオプション",
"search": {
"title": "予定を検索",
"clear": "検索をクリア",
"close": "検索を閉じる",
"upcoming": "今後の予定",
"past": "過去の予定",
"no_results": "「{query}」に一致する予定はありません",
"no_results_hint": "タイトル、場所、説明、ゲストを検索します。",
"repeats": "繰り返し",
"results": "{count, plural, other {# 件の予定が見つかりました}}"
}
},
"advanced_search": {
"title": "詳細検索",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "작업",
"previous": "이전 일/주/월",
"next": "다음 일/주/월",
"new_item": "새 이벤트 (할 일 보기에서는 새 작업)"
"new_item": "새 이벤트 (할 일 보기에서는 새 작업)",
"move_in_grid": "그리드에서 날짜와 시간 사이 이동"
},
"other_calendars": "다른 캘린더",
"calendar_options": "{name} 옵션"
"calendar_options": "{name} 옵션",
"search": {
"title": "일정 검색",
"clear": "검색어 지우기",
"close": "검색 닫기",
"upcoming": "예정",
"past": "지난 일정",
"no_results": "‘{query}’와 일치하는 일정이 없습니다",
"no_results_hint": "제목, 장소, 설명, 참석자에서 검색합니다.",
"repeats": "반복",
"results": "{count, plural, other {일정 #개를 찾았습니다}}"
}
},
"advanced_search": {
"title": "상세 검색",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Darbības",
"previous": "Iepriekšējā diena, nedēļa vai mēnesis",
"next": "Nākamā diena, nedēļa vai mēnesis",
"new_item": "Jauns notikums vai, sadaļā Uzdevumi, jauns uzdevums"
"new_item": "Jauns notikums vai, sadaļā Uzdevumi, jauns uzdevums",
"move_in_grid": "Pārvietoties starp dienām un stundām režģī"
},
"other_calendars": "Citi kalendāri",
"calendar_options": "{name} opcijas"
"calendar_options": "{name} opcijas",
"search": {
"title": "Meklēt notikumus",
"clear": "Notīrīt meklēšanu",
"close": "Aizvērt meklēšanu",
"upcoming": "Gaidāmie",
"past": "Pagājušie",
"no_results": "Neviens notikums neatbilst “{query}”",
"no_results_hint": "Meklē nosaukumos, vietās, aprakstos un viesos.",
"repeats": "Atkārtojas",
"results": "{count, plural, zero {Atrasti # notikumi} one {Atrasts # notikums} other {Atrasti # notikumi}}"
}
},
"advanced_search": {
"title": "Izvērstā meklēšana",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Үйлдлүүд",
"previous": "Өмнөх өдөр, долоо хоног эсвэл сар",
"next": "Дараах өдөр, долоо хоног эсвэл сар",
"new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар"
"new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар",
"move_in_grid": "Хүснэгтэд өдөр, цагийн хооронд шилжих"
},
"other_calendars": "Бусад хуанли",
"calendar_options": "{name}-ийн тохиргоо"
"calendar_options": "{name}-ийн тохиргоо",
"search": {
"title": "Үйл явдал хайх",
"clear": "Хайлтыг арилгах",
"close": "Хайлтыг хаах",
"upcoming": "Удахгүй болох",
"past": "Өнгөрсөн",
"no_results": "“{query}”-д тохирох үйл явдал алга",
"no_results_hint": "Гарчиг, газар, тайлбар, зочдоос хайна.",
"repeats": "Давтагдана",
"results": "{count, plural, one {# үйл явдал олдлоо} other {# үйл явдал олдлоо}}"
}
},
"calendar_event_notifications": {
"someone": "Хэн нэгэн",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Handlinger",
"previous": "Forrige dag, uke eller måned",
"next": "Neste dag, uke eller måned",
"new_item": "Ny hendelse, eller en ny oppgave i Oppgaver"
"new_item": "Ny hendelse, eller en ny oppgave i Oppgaver",
"move_in_grid": "Gå mellom dager og timer i rutenettet"
},
"other_calendars": "Andre kalendere",
"calendar_options": "Alternativer for {name}"
"calendar_options": "Alternativer for {name}",
"search": {
"title": "Søk i hendelser",
"clear": "Tøm søket",
"close": "Lukk søket",
"upcoming": "Kommende",
"past": "Tidligere",
"no_results": "Ingen hendelser samsvarer med «{query}»",
"no_results_hint": "Søket ser i titler, steder, beskrivelser og gjester.",
"repeats": "Gjentas",
"results": "{count, plural, one {# hendelse funnet} other {# hendelser funnet}}"
}
},
"calendar_event_notifications": {
"someone": "Noen",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Acties",
"previous": "Vorige dag, week of maand",
"next": "Volgende dag, week of maand",
"new_item": "Nieuwe afspraak, of een nieuwe taak in Taken"
"new_item": "Nieuwe afspraak, of een nieuwe taak in Taken",
"move_in_grid": "Tussen dagen en uren in het raster bewegen"
},
"other_calendars": "Andere agenda's",
"calendar_options": "Opties voor {name}"
"calendar_options": "Opties voor {name}",
"search": {
"title": "Afspraken zoeken",
"clear": "Zoekopdracht wissen",
"close": "Zoeken sluiten",
"upcoming": "Komend",
"past": "Voorbij",
"no_results": "Geen afspraken gevonden voor ‘{query}’",
"no_results_hint": "Er wordt gezocht in titels, locaties, beschrijvingen en gasten.",
"repeats": "Herhaalt",
"results": "{count, plural, one {# afspraak gevonden} other {# afspraken gevonden}}"
}
},
"advanced_search": {
"title": "Geavanceerd zoeken",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Akcje",
"previous": "Poprzedni dzień, tydzień lub miesiąc",
"next": "Następny dzień, tydzień lub miesiąc",
"new_item": "Nowe wydarzenie lub, w Zadaniach, nowe zadanie"
"new_item": "Nowe wydarzenie lub, w Zadaniach, nowe zadanie",
"move_in_grid": "Przechodzenie między dniami i godzinami w siatce"
},
"other_calendars": "Inne kalendarze",
"calendar_options": "Opcje dla {name}"
"calendar_options": "Opcje dla {name}",
"search": {
"title": "Szukaj wydarzeń",
"clear": "Wyczyść wyszukiwanie",
"close": "Zamknij wyszukiwanie",
"upcoming": "Nadchodzące",
"past": "Minione",
"no_results": "Brak wydarzeń pasujących do „{query}”",
"no_results_hint": "Wyszukiwanie obejmuje tytuły, miejsca, opisy i gości.",
"repeats": "Powtarza się",
"results": "{count, plural, one {Znaleziono # wydarzenie} few {Znaleziono # wydarzenia} many {Znaleziono # wydarzeń} other {Znaleziono # wydarzenia}}"
}
},
"advanced_search": {
"title": "Wyszukiwanie zaawansowane",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Ações",
"previous": "Dia, semana ou mês anterior",
"next": "Dia, semana ou mês seguinte",
"new_item": "Novo evento, ou uma nova tarefa em Tarefas"
"new_item": "Novo evento, ou uma nova tarefa em Tarefas",
"move_in_grid": "Mover entre dias e horas na grelha"
},
"other_calendars": "Outros calendários",
"calendar_options": "Opções de {name}"
"calendar_options": "Opções de {name}",
"search": {
"title": "Pesquisar eventos",
"clear": "Limpar pesquisa",
"close": "Fechar pesquisa",
"upcoming": "Próximos",
"past": "Passados",
"no_results": "Nenhum evento corresponde a “{query}”",
"no_results_hint": "A pesquisa procura em títulos, locais, descrições e convidados.",
"repeats": "Repete-se",
"results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}"
}
},
"advanced_search": {
"title": "Pesquisa avançada",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Acțiuni",
"previous": "Ziua, săptămâna sau luna anterioară",
"next": "Ziua, săptămâna sau luna următoare",
"new_item": "Eveniment nou sau, în Sarcini, o sarcină nouă"
"new_item": "Eveniment nou sau, în Sarcini, o sarcină nouă",
"move_in_grid": "Deplasare între zile și ore în grilă"
},
"other_calendars": "Alte calendare",
"calendar_options": "Opțiuni pentru {name}"
"calendar_options": "Opțiuni pentru {name}",
"search": {
"title": "Caută evenimente",
"clear": "Șterge căutarea",
"close": "Închide căutarea",
"upcoming": "Viitoare",
"past": "Trecute",
"no_results": "Niciun eveniment nu corespunde cu „{query}”",
"no_results_hint": "Căutarea se uită la titluri, locuri, descrieri și invitați.",
"repeats": "Se repetă",
"results": "{count, plural, one {# eveniment găsit} few {# evenimente găsite} other {# de evenimente găsite}}"
}
},
"calendar_event_notifications": {
"someone": "Cineva",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Действия",
"previous": "Предыдущий день, неделя или месяц",
"next": "Следующий день, неделя или месяц",
"new_item": "Новое событие, в Задачах — новая задача"
"new_item": "Новое событие, в Задачах — новая задача",
"move_in_grid": "Перемещение между днями и часами в сетке"
},
"other_calendars": "Другие календари",
"calendar_options": "Параметры: {name}"
"calendar_options": "Параметры: {name}",
"search": {
"title": "Поиск событий",
"clear": "Очистить поиск",
"close": "Закрыть поиск",
"upcoming": "Предстоящие",
"past": "Прошедшие",
"no_results": "Нет событий по запросу «{query}»",
"no_results_hint": "Поиск идёт по названиям, местам, описаниям и гостям.",
"repeats": "Повторяется",
"results": "{count, plural, one {Найдено # событие} few {Найдено # события} many {Найдено # событий} other {Найдено # события}}"
}
},
"advanced_search": {
"title": "Расширенный поиск",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Akcie",
"previous": "Predchádzajúci deň, týždeň alebo mesiac",
"next": "Nasledujúci deň, týždeň alebo mesiac",
"new_item": "Nová udalosť, v Úlohách nová úloha"
"new_item": "Nová udalosť, v Úlohách nová úloha",
"move_in_grid": "Presun medzi dňami a hodinami v mriežke"
},
"other_calendars": "Ďalšie kalendáre",
"calendar_options": "Možnosti pre {name}"
"calendar_options": "Možnosti pre {name}",
"search": {
"title": "Hľadať udalosti",
"clear": "Vymazať hľadanie",
"close": "Zavrieť hľadanie",
"upcoming": "Nadchádzajúce",
"past": "Minulé",
"no_results": "Hľadaniu „{query}“ nezodpovedá žiadna udalosť",
"no_results_hint": "Hľadá sa v názvoch, miestach, popisoch a hosťoch.",
"repeats": "Opakuje sa",
"results": "{count, plural, one {Nájdená # udalosť} few {Nájdené # udalosti} many {Nájdenej # udalosti} other {Nájdených # udalostí}}"
}
},
"calendar_event_notifications": {
"someone": "Niekto",
+14 -2
View File
@@ -3272,10 +3272,22 @@
"actions": "การทำงาน",
"previous": "วัน สัปดาห์ หรือเดือนก่อนหน้า",
"next": "วัน สัปดาห์ หรือเดือนถัดไป",
"new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน"
"new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน",
"move_in_grid": "เลื่อนไปมาระหว่างวันและชั่วโมงในตาราง"
},
"other_calendars": "ปฏิทินอื่นๆ",
"calendar_options": "ตัวเลือกสำหรับ {name}"
"calendar_options": "ตัวเลือกสำหรับ {name}",
"search": {
"title": "ค้นหากิจกรรม",
"clear": "ล้างการค้นหา",
"close": "ปิดการค้นหา",
"upcoming": "ที่กำลังจะมาถึง",
"past": "ที่ผ่านมา",
"no_results": "ไม่มีกิจกรรมที่ตรงกับ “{query}”",
"no_results_hint": "ค้นหาจากชื่อ สถานที่ คำอธิบาย และแขก",
"repeats": "เกิดซ้ำ",
"results": "{count, plural, other {พบ # กิจกรรม}}"
}
},
"settings": {
"title": "การตั้งค่า",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Eylemler",
"previous": "Önceki gün, hafta veya ay",
"next": "Sonraki gün, hafta veya ay",
"new_item": "Yeni etkinlik, Görevler'de yeni görev"
"new_item": "Yeni etkinlik, Görevler'de yeni görev",
"move_in_grid": "Izgarada günler ve saatler arasında gezin"
},
"other_calendars": "Diğer takvimler",
"calendar_options": "{name} seçenekleri"
"calendar_options": "{name} seçenekleri",
"search": {
"title": "Etkinlik ara",
"clear": "Aramayı temizle",
"close": "Aramayı kapat",
"upcoming": "Yaklaşan",
"past": "Geçmiş",
"no_results": "“{query}” ile eşleşen etkinlik yok",
"no_results_hint": "Arama başlıklarda, yerlerde, açıklamalarda ve davetlilerde yapılır.",
"repeats": "Tekrarlanıyor",
"results": "{count, plural, one {# etkinlik bulundu} other {# etkinlik bulundu}}"
}
},
"calendar_event_notifications": {
"someone": "Birisi",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "Дії",
"previous": "Попередній день, тиждень або місяць",
"next": "Наступний день, тиждень або місяць",
"new_item": "Нова подія, у Завданнях — нове завдання"
"new_item": "Нова подія, у Завданнях — нове завдання",
"move_in_grid": "Переміщення між днями й годинами в сітці"
},
"other_calendars": "Інші календарі",
"calendar_options": "Параметри: {name}"
"calendar_options": "Параметри: {name}",
"search": {
"title": "Пошук подій",
"clear": "Очистити пошук",
"close": "Закрити пошук",
"upcoming": "Майбутні",
"past": "Минулі",
"no_results": "Немає подій за запитом «{query}»",
"no_results_hint": "Пошук іде за назвами, місцями, описами й гостями.",
"repeats": "Повторюється",
"results": "{count, plural, one {Знайдено # подію} few {Знайдено # події} many {Знайдено # подій} other {Знайдено # події}}"
}
},
"advanced_search": {
"title": "Розширений пошук",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "動作",
"previous": "上一天、週或月",
"next": "下一天、週或月",
"new_item": "新增活動(在任務中新增任務)"
"new_item": "新增活動(在任務中新增任務)",
"move_in_grid": "在格線中按天與小時移動"
},
"other_calendars": "其他日曆",
"calendar_options": "{name} 的選項"
"calendar_options": "{name} 的選項",
"search": {
"title": "搜尋活動",
"clear": "清除搜尋",
"close": "關閉搜尋",
"upcoming": "即將到來",
"past": "已過去",
"no_results": "沒有符合「{query}」的活動",
"no_results_hint": "搜尋範圍包括標題、地點、說明和邀請對象。",
"repeats": "重複",
"results": "{count, plural, other {找到 # 個活動}}"
}
},
"calendar_event_notifications": {
"someone": "有人",
+14 -2
View File
@@ -4148,10 +4148,22 @@
"actions": "操作",
"previous": "上一天、周或月",
"next": "下一天、周或月",
"new_item": "新建事件(在任务中新建任务)"
"new_item": "新建事件(在任务中新建任务)",
"move_in_grid": "在网格中按天和小时移动"
},
"other_calendars": "其他日历",
"calendar_options": "{name} 的选项"
"calendar_options": "{name} 的选项",
"search": {
"title": "搜索活动",
"clear": "清除搜索",
"close": "关闭搜索",
"upcoming": "即将到来",
"past": "已过去",
"no_results": "没有与“{query}”匹配的活动",
"no_results_hint": "搜索范围包括标题、地点、描述和邀请对象。",
"repeats": "重复",
"results": "{count, plural, other {找到 # 个活动}}"
}
},
"advanced_search": {
"title": "高级搜索",