mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'main' into ux/mail2
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ?? {})) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Розширений пошук",
|
||||
|
||||
@@ -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
@@ -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": "高级搜索",
|
||||
|
||||
Reference in New Issue
Block a user