Merge branch 'ux/calendar3'

This commit is contained in:
Linus Rath
2026-10-28 11:06:37 +01:00
61 changed files with 1013 additions and 89 deletions
@@ -126,6 +126,18 @@ describe('CalendarApp keyboard shortcuts', () => {
fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' });
expect(useCalendarStore.getState().viewMode).toBe('month');
});
// New 6: the preview opened on top of the open editor.
it('opens no hover preview while an editor is open', async () => {
setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Retro', start: '2026-10-13T14:00:00' })]);
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
fireEvent.click(await screen.findByRole('button', { name: 'events.edit' }));
await screen.findByRole('dialog', { name: 'events.edit' });
fireEvent.mouseEnter(screen.getAllByRole('button', { name: /Retro/ })[0]);
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByRole('dialog', { name: 'Retro' })).toBeNull();
});
});
@@ -340,6 +352,18 @@ describe('CalendarApp event search (CAL-16)', () => {
expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument();
});
it('offers Clear search when nothing matches, back to the calendar with the cursor in the box', async () => {
renderApp();
const box = screen.getByRole('searchbox', { name: 'search.title' });
fireEvent.change(box, { target: { value: 'zzz' } });
await screen.findByText('search.no_results', {}, { timeout: 3000 });
const results = screen.getByTestId('calendar-search-results');
fireEvent.click(within(results).getByRole('button', { name: 'search.clear' }));
expect(box).toHaveValue('');
expect(document.activeElement).toBe(box);
await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull());
});
it('is in the command palette', () => {
render(<><CalendarApp /><PaletteGroups /></>);
const items = screen.getByTestId('palette').textContent ?? '';
@@ -378,7 +402,7 @@ describe('CalendarApp deleting', () => {
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' }));
expect(await screen.findByRole('dialog', { name: 'delete_title' })).toBeInTheDocument();
expect(await screen.findByRole('dialog', { name: 'delete_question' })).toBeInTheDocument();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
@@ -0,0 +1,66 @@
import { render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarDayView } from '../calendar-day-view';
import type { Calendar, CalendarEvent, CalendarTask } from '@/lib/jmap/types';
// CAL-27 follow-up: the phone day view drew tasks as 10px chips with a
// circle too small to tap, and a busy neighbouring day left an empty
// all-day block of ~150px above the hours of a day with none.
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
const holiday = {
id: 'ev-1', calendarIds: { 'cal-1': true }, title: 'Holiday', start: '2026-09-10T00:00:00',
duration: 'P1D', showWithoutTime: true,
} as unknown as CalendarEvent;
const task = {
id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true },
progress: 'needs-action', priority: 0, due: '2026-09-09', showWithoutTime: true,
} as unknown as CalendarTask;
function renderDay(isMobile: boolean, tasks: CalendarTask[] = [task]) {
return render(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-08"
rangeStart={new Date(2026, 8, 8)}
rangeEnd={new Date(2026, 8, 10)}
selectedDate={new Date(2026, 8, 9)}
events={[holiday]}
calendars={calendars}
tasks={tasks}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
isMobile={isMobile}
/>,
);
}
describe('day view on a phone', () => {
it('gives each task a full row with a 44px target for the circle', () => {
renderDay(true);
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
expect(chip).toHaveClass('min-h-11', 'text-xs');
expect(within(chip).getByRole('button', { name: /mark_complete|Mark as complete/i })).toHaveClass('w-11', 'self-stretch');
});
it('keeps the small shared chip on a wide screen', () => {
renderDay(false);
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
expect(chip).toHaveClass('h-full', 'text-[10px]');
});
it('sizes the all-day row by the day in view, not by a busy neighbour', () => {
renderDay(true, []);
const today = document.querySelector<HTMLElement>('[data-day="2026-09-09"]')!;
const next = document.querySelector<HTMLElement>('[data-day="2026-09-10"]')!;
// The day in view has nothing all-day: no empty block under its date.
expect(within(today).queryByText(/all_day|All day/i)).toBeNull();
expect(today).not.toHaveClass('h-0');
// The neighbour's all-day event takes no height of its own.
expect(within(next).getByRole('button', { name: /Holiday/ })).toBeInTheDocument();
expect(next).toHaveClass('h-0', 'min-h-full', 'overflow-hidden');
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
});
});
@@ -61,6 +61,16 @@ describe('import dialog from the keyboard', () => {
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument();
});
// It focused the hidden file input, which can't take the focus, so the
// focus stayed on the page behind and Tab walked the calendar.
it('opens with the focus on the drop zone, or on the address field for a link', () => {
const { unmount } = render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByRole('button', { name: /select_file/ }));
unmount();
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} initialUrl="https://example.com/a.ics" />);
expect(document.activeElement).toBe(screen.getByLabelText('tab_url'));
});
});
describe('import preview', () => {
@@ -0,0 +1,64 @@
import { render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { EventCard } from '../event-card';
import { CalendarSearchResults } from '../calendar-search';
import { CalendarAgendaView } from '../calendar-agenda-view';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// In Arabic, an English title took the page's direction (".Dinner with
// Carlos and co", cut at the start) and "09:15 – 09:30" read "09:30 – 09:15".
vi.mock('@/lib/timezone', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/timezone')>()),
displayNow: () => new Date(2026, 9, 28, 8, 0),
}));
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
const dinner = {
id: 'e1', '@type': 'Event', uid: 'e1', title: 'Dinner with Carlos and co.', start: '2026-10-28T09:15:00',
duration: 'PT1H', showWithoutTime: false, status: 'confirmed', calendarIds: { 'cal-1': true },
} as unknown as CalendarEvent;
const isolatedLtr = (el: HTMLElement) => el.closest('bdi')?.getAttribute('dir') === 'ltr';
describe('user text and times in a right-to-left layout', () => {
it.each(['chip', 'span', 'block'] as const)('gives the event title its own direction (%s)', (variant) => {
render(<EventCard event={dinner} variant={variant} />);
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
});
it('keeps start before end in a block', () => {
render(<EventCard event={dinner} variant="block" />);
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
});
it('does the same in search results', () => {
render(
<CalendarSearchResults
query="dinner"
results={{ upcoming: [dinner], past: [], searching: false }}
calendars={calendars}
onSelectEvent={vi.fn()}
/>,
);
const row = screen.getByRole('button', { name: /Dinner/ });
expect(within(row).getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
expect(isolatedLtr(within(row).getByText('09:15 – 10:15'))).toBe(true);
});
it('does the same in the agenda', () => {
render(
<CalendarAgendaView
focus={{ date: new Date(2026, 9, 28), nonce: 0 }}
windowKey="agenda:2026-10-28"
rangeStart={new Date(2026, 9, 28)}
rangeEnd={new Date(2026, 10, 3)}
events={[dinner]}
calendars={calendars}
onSelectEvent={vi.fn()}
/>,
);
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
});
});
@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarToolbar } from '../calendar-toolbar';
// The phone day view's title was cut to "Th…": the long date beside the
// menu, the back arrow, both chevrons, Today and the header buttons.
vi.mock('@/components/layout/phone-header-actions', () => ({ PhoneHeaderActions: () => null }));
function renderToolbar(overrides: Partial<React.ComponentProps<typeof CalendarToolbar>> = {}) {
render(
<CalendarToolbar
selectedDate={new Date(2026, 9, 28)}
viewMode="day"
onPrev={vi.fn()}
onNext={vi.fn()}
onToday={vi.fn()}
onViewModeChange={vi.fn()}
onCreateEvent={vi.fn()}
isMobile
onMenuClick={vi.fn()}
{...overrides}
/>,
);
}
describe('phone toolbar in the day view', () => {
it('names the day short, with the full date as its tooltip', () => {
renderToolbar();
const title = screen.getByText('Oct 28');
expect(title).toHaveAttribute('title', 'Wednesday, October 28, 2026');
});
it('puts the way back to the month in the menu button\'s place', () => {
renderToolbar({ onNavigateBack: vi.fn() });
expect(screen.getByRole('button', { name: 'back_to_month' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'nav_open_menu' })).toBeNull();
});
it('keeps the menu when there is no way back', () => {
renderToolbar();
expect(screen.getByRole('button', { name: 'nav_open_menu' })).toBeInTheDocument();
});
});
@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import { CalendarDayView } from '../calendar-day-view';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// Tab in the week view walked into the events of the weeks loaded on
// either side and scrolled the grid off its week.
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
function makeEvent(id: string, title: string, start: string, extra: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id, uid: id, '@type': 'Event', title, start, duration: 'PT1H', showWithoutTime: false,
calendarIds: { 'cal-1': true }, ...extra,
} as unknown as CalendarEvent;
}
const events = [
makeEvent('before', 'Last week', '2026-09-02T10:00:00'),
makeEvent('now', 'This week', '2026-09-09T10:00:00'),
makeEvent('after', 'Next week', '2026-09-16T10:00:00'),
makeEvent('trip', 'Trip', '2026-09-03T00:00:00', { showWithoutTime: true, duration: 'P1D' }),
makeEvent('fair', 'Fair', '2026-09-08T00:00:00', { showWithoutTime: true, duration: 'P1D' }),
];
const tabbable = (name: RegExp) => screen.getByRole('button', { name }).tabIndex >= 0;
describe('tab order of the week view', () => {
it('leaves the events of the weeks on either side out of the tab order', () => {
render(
<CalendarWeekView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="week:2026-08-31"
// Three weeks loaded: 31 Aug to 20 Sep, the middle one in view.
rangeStart={new Date(2026, 7, 31)}
rangeEnd={new Date(2026, 8, 20)}
selectedDate={new Date(2026, 8, 9)}
events={events}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
expect(tabbable(/^This week/)).toBe(true);
expect(tabbable(/^Fair/)).toBe(true);
expect(tabbable(/^Last week/)).toBe(false);
expect(tabbable(/^Next week/)).toBe(false);
expect(tabbable(/^Trip/)).toBe(false);
});
it('keeps the grid tab stop on a day in view when the selected day is scrolled away', () => {
render(
<CalendarWeekView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="week:2026-08-31"
rangeStart={new Date(2026, 7, 31)}
rangeEnd={new Date(2026, 8, 20)}
selectedDate={new Date(2026, 8, 2)}
events={[]}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
const stops = screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0);
expect(stops).toHaveLength(1);
expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-07T/);
});
it('does the same in the day view', () => {
render(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-08"
rangeStart={new Date(2026, 8, 8)}
rangeEnd={new Date(2026, 8, 10)}
selectedDate={new Date(2026, 8, 9)}
events={[makeEvent('mon', 'Monday', '2026-09-08T10:00:00'), makeEvent('tue', 'Tuesday', '2026-09-09T10:00:00')]}
calendars={calendars}
onSelectEvent={vi.fn()}
onCreateAtTime={vi.fn()}
/>,
);
expect(tabbable(/^Tuesday/)).toBe(true);
expect(tabbable(/^Monday/)).toBe(false);
});
});
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { EventDetailPopover } from '../event-detail-popover';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// CAL-13: Enter on an event opened the card but the focus stayed on the
// event: the card focused its first button while it was still hidden,
// before it had been placed.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
}));
const calendar = {
id: 'cal-1',
name: 'Work',
myRights: { mayWriteAll: true, mayWriteOwn: true },
} as unknown as Calendar;
const event = {
id: 'evt-1',
calendarIds: { 'cal-1': true },
title: 'Review',
start: '2026-11-20T10:00:00',
duration: 'PT1H',
timeZone: 'Europe/Berlin',
showWithoutTime: false,
participants: null,
alerts: null,
} as unknown as CalendarEvent;
describe('EventDetailPopover focus', () => {
it('takes the focus once it is visible, and gives it back to the event on close', () => {
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
const visibilityAtFocus: string[] = [];
const onFocusIn = (e: FocusEvent) => {
const card = (e.target as HTMLElement).closest<HTMLElement>('[role="dialog"]');
if (card) visibilityAtFocus.push(card.style.visibility);
};
document.addEventListener('focusin', onFocusIn);
const { unmount } = render(
<EventDetailPopover
event={event}
calendar={calendar}
anchorRect={new DOMRect(100, 100, 50, 20)}
pinned
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
const card = screen.getByRole('dialog', { name: 'Review' });
expect(card.contains(document.activeElement)).toBe(true);
expect(visibilityAtFocus).toEqual(['visible']);
unmount();
document.removeEventListener('focusin', onFocusIn);
expect(document.activeElement).toBe(opener);
opener.remove();
});
});
@@ -0,0 +1,35 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { RecurrenceScopeDialog } from '../recurrence-scope-dialog';
// The delete scope dialog asked "Which events would you like to modify?":
// no word of what is lost (R3), and a title without a question mark.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key}(${Object.values(values).join(',')})` : key,
}));
describe('RecurrenceScopeDialog', () => {
it('asks to delete with a question and says what goes for the chosen scope', () => {
render(
<RecurrenceScopeDialog isOpen actionType="delete" eventTitle="Yoga" note="cancel_notification" onSelect={vi.fn()} onClose={vi.fn()} />,
);
const dialog = screen.getByRole('dialog', { name: 'delete_question' });
expect(dialog).toHaveAccessibleDescription('delete_this_message(Yoga)');
fireEvent.click(screen.getByLabelText('all_events'));
expect(dialog).toHaveAccessibleDescription('delete_all_message(Yoga)');
fireEvent.click(screen.getByLabelText('this_and_future'));
expect(dialog).toHaveAccessibleDescription('delete_following_message(Yoga)');
// The cancellation note for an organizer's event with participants
expect(screen.getByText('cancel_notification')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'delete' }).className).toMatch(/destructive/);
});
it('keeps the edit wording for an edit', () => {
render(<RecurrenceScopeDialog isOpen actionType="edit" eventTitle="Yoga" onSelect={vi.fn()} onClose={vi.fn()} />);
const dialog = screen.getByRole('dialog', { name: 'edit_title' });
expect(dialog).toHaveAccessibleDescription('description');
expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();
});
});
@@ -0,0 +1,49 @@
import { render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { TaskModal } from '../task-modal';
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
// The task editor's Calendar select offered the read-only Birthdays
// calendar, which the event editor leaves out.
const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true };
const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false };
const calendars = [
{ id: 'work', name: 'Work', isShared: false, myRights: writable },
{ id: 'personal', name: 'Personal', isShared: false, myRights: writable },
{ id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly },
{ id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable },
] as unknown as Calendar[];
const isSubscriptionCalendar = (id: string) => id === 'feed';
describe('task editor calendar select', () => {
it('lists only the calendars a task can go into', () => {
render(<TaskModal calendars={calendars} isSubscriptionCalendar={isSubscriptionCalendar} onSave={vi.fn()} onClose={vi.fn()} />);
const select = screen.getByLabelText('tasks.calendar');
const names = within(select).getAllByRole('option').map((o) => o.textContent);
expect(names).toEqual(['Work', 'Personal']);
});
it('keeps the calendar of a task that is already in a read-only one', () => {
const task = {
id: 't1', '@type': 'Task', title: 'Card for Ann', calendarIds: { birthdays: true },
progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null,
} as unknown as CalendarTask;
render(<TaskModal task={task} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement;
expect(select.value).toBe('birthdays');
});
});
// The desktop task panel had no dialog role, unlike the event editor.
describe('task editor panel', () => {
it('is a dialog named by its title on wide screens', () => {
render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument();
});
it('leaves the role to the full-screen wrapper on a phone', () => {
render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} isMobile />);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
+8 -4
View File
@@ -263,7 +263,7 @@ export function CalendarAgendaView({
style={{ paddingBlock: 'var(--density-item-py)' }}
>
<span className="w-28 sm:w-32 flex-shrink-0 text-sm text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
</span>
{/* Calendar colour as a dot on the title line; hollow when
@@ -275,13 +275,17 @@ export function CalendarAgendaView({
/>
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
{ev.title || t("events.no_title")}
{/* Sized to the title, which keeps its own direction: it sits at
the start of the row and is cut at its own end. */}
<div className="flex min-w-0">
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
{ev.title || t("events.no_title")}
</span>
</div>
{(meta || getParticipantCount(ev) > 0) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" />}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
{getParticipantCount(ev) > 0 && (
<span className="flex items-center gap-1 flex-shrink-0">
<Users className="w-3 h-3" />
+12 -3
View File
@@ -91,6 +91,7 @@ import { useCalendarEventSearch } from "@/hooks/use-calendar-event-search";
import { CalendarSearchField, CalendarSearchResults } from "@/components/calendar/calendar-search";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { useHoverPreviewGate } from "@/hooks/use-hover-preview-gate";
import {
computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, normalizeScrollWindowState,
scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions,
@@ -842,16 +843,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
[closeDetail, openEmptyContextMenu],
);
// No preview over an open editor, card or dialog, nor after one closed
// until the pointer moves.
const canHoverPreview = useHoverPreviewGate(showEventModal || showTaskModal || detailPinned || quickDraft !== null);
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (detailPinnedRef.current) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
if (showEventModal && editEvent?.id === event.id) return;
if (!canHoverPreview()) return;
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
hoverTimerRef.current = setTimeout(() => {
if (!canHoverPreview()) return;
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, ms);
@@ -859,7 +864,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
}, [isMobile, calendarHoverPreview, canHoverPreview]);
const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
@@ -1790,6 +1795,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
onContextMenuEvent={handleContextMenuEvent}
onClearSearch={() => { setSearchQuery(""); searchInputRef.current?.focus(); }}
/>
);
}
@@ -2213,6 +2219,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
key={editTask?.id ?? 'new-task'}
task={editTask}
calendars={displayCalendars}
isSubscriptionCalendar={isSubscriptionCalendar}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onDirtyChange={setTaskEditorDirty}
@@ -2375,6 +2382,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
key={editTask?.id ?? 'new-task'}
task={editTask}
calendars={displayCalendars}
isSubscriptionCalendar={isSubscriptionCalendar}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onDirtyChange={setTaskEditorDirty}
@@ -2430,6 +2438,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
&& userOrganizes(pendingScopeAction.event, currentUserEmails)
? t("participants.cancel_notification")
: undefined}
eventTitle={pendingScopeAction?.event.title || t("events.no_title")}
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
+31 -14
View File
@@ -197,14 +197,6 @@ export function CalendarDayView({
return map;
}, [days, eventsByDay, hours]);
const hasAllDayArea = useMemo(
() => days.some((day) => {
const key = format(day, "yyyy-MM-dd");
return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0;
}),
[days, eventsByDay, tasksByDay],
);
const scrollToFocus = useCallback(() => {
const root = rootRef.current;
if (!root) return;
@@ -232,6 +224,10 @@ export function CalendarDayView({
// not used: browsers re-snap on their own when columns are prepended,
// which would double the scroll correction.)
const visibleKeyRef = useRef<string | null>(null);
// The day in view: its all-day events and tasks size the row under the
// dates, so a busy neighbour doesn't leave an empty block above the hours.
const [visibleKey, setVisibleKey] = useState(() => dayKey(focus.date));
useEffect(() => { setVisibleKey(dayKey(focus.date)); }, [focus.date, focus.nonce]);
const scrollFrameRef = useRef<number | null>(null);
const snapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleStripScroll = useCallback(() => {
@@ -255,6 +251,7 @@ export function CalendarDayView({
const key = dayKey(day);
if (key === visibleKeyRef.current) return;
visibleKeyRef.current = key;
setVisibleKey(key);
onVisibleDateChange?.(day);
});
}, [colWidth, colCount, days, onVisibleDateChange]);
@@ -298,6 +295,10 @@ export function CalendarDayView({
onMoveEvent,
});
// Tab stays in the day in view: the loaded days on either side are out of
// the tab order, or Tab walked into them and scrolled the strip.
const isInView = useCallback((key: string) => key === visibleKey, [visibleKey]);
const { slotProps } = useTimeGridFocus({
rootRef,
headerRef,
@@ -306,6 +307,7 @@ export function CalendarDayView({
hours,
selectedDate,
onCreate: onCreateAtTime,
isInView,
});
const formatHour = (h: number): string => {
@@ -352,7 +354,16 @@ export function CalendarDayView({
const allDayEvents = eventsByDay.get(key)?.allDay ?? [];
const dayTasks = tasksByDay.get(key) ?? [];
return (
<div key={key} data-day={key} className="min-w-0 border-e border-border last:border-e-0">
<div
key={key}
data-day={key}
className={cn(
"min-w-0 border-e border-border last:border-e-0",
// Other days take no height of their own: they fill the
// row the day in view sets, clipped while swiping past.
key !== visibleKey && "h-0 min-h-full overflow-hidden",
)}
>
<div
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
role="columnheader"
@@ -376,7 +387,7 @@ export function CalendarDayView({
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
</div>
{hasAllDayArea && (
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
<div
className="px-4 py-2 border-t border-border"
onContextMenu={onContextMenuEmpty ? (e) => {
@@ -402,6 +413,7 @@ export function CalendarDayView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
tabbable={key === visibleKey}
/>
);
})}
@@ -414,12 +426,14 @@ export function CalendarDayView({
<div className="space-y-0.5">
{/* The same chip as the month and week grids. */}
{dayTasks.map((task) => (
<div key={task.id} className="h-5">
<div key={task.id} className={isMobile ? undefined : "h-5"}>
<CalendarTaskChip
task={task}
calendar={calendars.find((c) => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
touch={isMobile}
tabbable={key === visibleKey}
/>
</div>
))}
@@ -487,7 +501,7 @@ export function CalendarDayView({
onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary"
style={{ height: hourHeight }}
/>
))}
@@ -497,6 +511,7 @@ export function CalendarDayView({
count={dayLayout.before}
direction="before"
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
tabbable={key === visibleKey}
/>
)}
{dayLayout && dayLayout.after > 0 && (
@@ -504,6 +519,7 @@ export function CalendarDayView({
count={dayLayout.after}
direction="after"
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
tabbable={key === visibleKey}
/>
)}
@@ -541,6 +557,7 @@ export function CalendarDayView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
tabbable={key === visibleKey}
draggable={editable}
continuesBefore={clip.clippedStart}
continuesAfter={clip.clippedEnd}
@@ -603,7 +620,7 @@ export function CalendarDayView({
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
</div>
</div>
)}
@@ -649,7 +666,7 @@ export function CalendarDayView({
{pendingPreview.title}
</div>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
</div>
</div>
);
+15 -5
View File
@@ -6,6 +6,7 @@ import { format } from "date-fns";
import { Search, X, MapPin, Repeat } from "@/components/icons";
import { Input } from "@/components/ui/input";
import { IconButton } from "@/components/ui/icon-button";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
@@ -88,11 +89,13 @@ interface CalendarSearchResultsProps {
currentUserEmails?: string[];
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
/** Empties the search box, back to the calendar (R9). */
onClearSearch?: () => void;
}
/** The events a calendar search found, upcoming first, then past ones. */
export function CalendarSearchResults({
query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent,
query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, onClearSearch,
}: CalendarSearchResultsProps) {
const t = useTranslations("calendar");
const intlFormatter = useDisplayDateFormatter();
@@ -148,7 +151,7 @@ export function CalendarSearchResults({
: { month: "short", day: "numeric", year: "numeric" })}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
</span>
</span>
<span
@@ -157,13 +160,15 @@ export function CalendarSearchResults({
aria-hidden="true"
/>
<span className="flex-1 min-w-0">
<span className={cn("block text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
{title}
<span className="flex min-w-0">
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
{title}
</span>
</span>
{(meta || repeats) && (
<span className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" aria-hidden="true" />}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
{repeats && (
<span className="flex items-center gap-1 flex-shrink-0">
<Repeat className="w-3 h-3" aria-hidden="true" />
@@ -199,6 +204,11 @@ export function CalendarSearchResults({
icon={Search}
title={t("search.no_results", { query: query.trim() })}
description={t("search.no_results_hint")}
actions={onClearSearch && (
<Button size="sm" variant="outline" onClick={onClearSearch}>
{t("search.clear")}
</Button>
)}
/>
)}
{section("calendar-search-upcoming", t("search.upcoming"), upcoming)}
+12 -4
View File
@@ -106,6 +106,7 @@ export function CalendarToolbar({
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
formatDayShort,
} = useCalendarLocale();
const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"]
@@ -152,8 +153,9 @@ export function CalendarToolbar({
: formatWeekRange(ws);
}
case "day":
// A phone's row has room for "Oct 28"; the full date is the tooltip.
return isMobile
? formatFullDate(titleDate)
? formatDayShort(titleDate)
: formatFullDate(titleDate);
case "agenda":
return isMobile
@@ -227,7 +229,8 @@ export function CalendarToolbar({
) : (
/* Row 1: Back / Date nav / Today */
<div className="flex items-center gap-1">
{onMenuClick && (
{/* The way back to the month takes the menu's place: both left the date no room. */}
{onMenuClick && !onNavigateBack && (
<IconButton size="icon-lg" label={t("nav_open_menu")} onClick={onMenuClick} className="-ms-1 touch-manipulation">
<Menu />
</IconButton>
@@ -242,7 +245,10 @@ export function CalendarToolbar({
<ChevronLeft />
</IconButton>
)}
<span className="text-sm font-semibold text-center flex-1 select-none truncate" title={getDateLabel()}>
<span
className="text-sm font-semibold text-center flex-1 select-none truncate"
title={viewMode === "day" ? formatFullDate(titleDate) : getDateLabel()}
>
{getDateLabel()}
</span>
{hasDateNav && (
@@ -261,6 +267,8 @@ export function CalendarToolbar({
)}
{/* Row 2: View switcher pills + calendar toggle */}
{/* Each pill is as wide as its label plus an even share of the rest, so a
long label ("جدول الأعمال", "Aufgaben") neither wraps nor touches the edge. */}
<div className="flex items-center gap-1.5">
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
{views.map((v) => (
@@ -268,7 +276,7 @@ export function CalendarToolbar({
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 min-h-11 text-[11px] font-medium transition-colors touch-manipulation",
"flex-auto min-h-11 px-1.5 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
+20 -3
View File
@@ -394,6 +394,17 @@ export function CalendarWeekView({
onMoveEvent,
});
// The days in view. Tab stays in them: events of the weeks loaded on
// either side are out of the tab order, or Tab walked into them and
// scrolled the grid off its week.
const inViewKeys = useMemo(
() => new Set(days.slice(visibleStartIndex, visibleStartIndex + visibleColumns).map(dayKey)),
[days, visibleStartIndex, visibleColumns],
);
const isInView = useCallback((key: string) => inViewKeys.has(key), [inViewKeys]);
const spanInView = (startIndex: number, span: number) =>
startIndex < visibleStartIndex + visibleColumns && startIndex + span > visibleStartIndex;
const { slotProps, tabStopDayKey } = useTimeGridFocus({
rootRef,
headerRef,
@@ -402,6 +413,7 @@ export function CalendarWeekView({
hours,
selectedDate,
onCreate: onCreateAtTime,
isInView,
});
const formatHour = (h: number): string => {
@@ -553,6 +565,7 @@ export function CalendarWeekView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
tabbable={spanInView(segment.startIndex, segment.span)}
/>
</div>
);
@@ -606,6 +619,7 @@ export function CalendarWeekView({
calendar={calendars.find(c => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
tabbable={inViewKeys.has(key)}
/>
</div>
));
@@ -671,7 +685,7 @@ export function CalendarWeekView({
onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary"
style={{ height: hourHeight }}
/>
))}
@@ -681,6 +695,7 @@ export function CalendarWeekView({
count={dayLayout.before}
direction="before"
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
tabbable={inViewKeys.has(key)}
/>
)}
{dayLayout && dayLayout.after > 0 && (
@@ -688,6 +703,7 @@ export function CalendarWeekView({
count={dayLayout.after}
direction="after"
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
tabbable={inViewKeys.has(key)}
/>
)}
@@ -728,6 +744,7 @@ export function CalendarWeekView({
draggable={editable}
continuesBefore={clip.clippedStart}
continuesAfter={clip.clippedEnd}
tabbable={inViewKeys.has(key)}
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{editable && !clip.clippedStart && (
@@ -787,7 +804,7 @@ export function CalendarWeekView({
}}
>
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
</div>
</div>
)}
@@ -834,7 +851,7 @@ export function CalendarWeekView({
{pendingPreview.title}
</div>
<div className="text-[10.5px] opacity-85 truncate">
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
</div>
</div>
);
@@ -9,13 +9,15 @@ interface HiddenEventsIndicatorProps {
count: number;
direction: "before" | "after";
onReveal: () => void;
/** False for a day scrolled out of view: Tab skips it. */
tabbable?: boolean;
}
/**
* Marks a day column that has events above or below the visible hours
* (#1164); clicking it shows the whole day at the first of them.
*/
export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEventsIndicatorProps) {
export function HiddenEventsIndicator({ count, direction, onReveal, tabbable = true }: HiddenEventsIndicatorProps) {
const t = useTranslations("calendar.events");
const label = direction === "before" ? t("hidden_before", { count }) : t("hidden_after", { count });
const Icon = direction === "before" ? ChevronUp : ChevronDown;
@@ -23,6 +25,7 @@ export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEven
<button
type="button"
data-hidden-events={direction}
tabIndex={tabbable ? undefined : -1}
title={label}
aria-label={label}
// The column starts drag-to-create on pointer down.
+11 -5
View File
@@ -33,6 +33,8 @@ interface EventCardProps {
style?: CSSProperties;
/** The user's calendar addresses, to tell whether they declined the event. */
currentUserEmails?: string[];
/** False for a day scrolled out of view: Tab skips it (arrows still reach it). */
tabbable?: boolean;
}
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
@@ -98,7 +100,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
return el;
}
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails }: EventCardProps) {
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
@@ -184,6 +186,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
{...dragProps}
className={cn(
"flex items-center w-full text-start text-xs font-medium px-1.5 py-0.5 rounded-xs truncate",
@@ -194,7 +197,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)}
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }}
>
<span className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -208,6 +211,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
{...dragProps}
className={cn(
"w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
@@ -223,7 +227,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 font-normal opacity-85">{format(startDate, timeFmt)}</span>
)}
<span className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -237,6 +241,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
{...dragProps}
data-calendar-event
data-continues-before={continuesBefore || undefined}
@@ -263,7 +268,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
block is wide enough to keep the title readable. */}
<div className="@container">
<div className="flex items-baseline gap-1 min-w-0">
<span className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
<span dir="auto" className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
{!event.showWithoutTime && durationMinutes < 45 && (
<span className="hidden @min-[120px]:inline flex-shrink-0 opacity-85 text-[10.5px]">{safeFormat(startDate, timeFmt)}</span>
)}
@@ -271,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
</div>
{!event.showWithoutTime && durationMinutes >= 45 && (
<div className="opacity-85 text-[10.5px]">
{timeString}
{/* Start before end in a right-to-left layout too. */}
<bdi dir="ltr">{timeString}</bdi>
</div>
)}
{getParticipantCount(event) > 0 && (
+31 -6
View File
@@ -253,17 +253,40 @@ export function EventDetailPopover({
return () => observer.disconnect();
}, [anchorRect, isMobile]);
// A card opened by click or Enter takes the focus, and gives it back to
// the event when it closes.
useEffect(() => {
if (!pinned) return;
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const node = popoverRef.current;
node?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
return () => {
const active = document.activeElement;
if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true });
};
}, [pinned]);
// The focus goes in once the card is placed: before, it is hidden and
// can't take the focus, which stayed on the event. A card that just
// turned visible can still count as hidden for a frame, so it tries again
// on the next ones, unless the focus moved somewhere else meanwhile.
const placed = position !== null;
useEffect(() => {
const node = popoverRef.current;
if (!pinned || !placed || !node) return;
const from = document.activeElement;
let frame = 0;
let tries = 0;
const attempt = () => {
const active = document.activeElement;
if (node.contains(active) || active !== from) return;
const first = node.querySelector<HTMLElement>("button");
first?.focus({ preventScroll: true });
if (first && document.activeElement !== first && ++tries < 30) frame = requestAnimationFrame(attempt);
};
attempt();
return () => cancelAnimationFrame(frame);
}, [pinned, placed]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -400,7 +423,8 @@ export function EventDetailPopover({
"text-lg font-semibold leading-snug break-words text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
{/* Its own direction, next to the colour square. */}
<bdi>{event.title || t("events.no_title")}</bdi>
</h3>
{event.status === "tentative" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-amber-600 dark:text-amber-400">
@@ -463,7 +487,7 @@ export function EventDetailPopover({
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} – {formatTime(endDate)}
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
@@ -481,6 +505,7 @@ export function EventDetailPopover({
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline min-w-0 break-words"
dir="auto"
title={locationName}
>
{/^https?:\/\//i.test(locationName)
@@ -534,7 +559,7 @@ export function EventDetailPopover({
<div className="mt-1 space-y-0.5">
{participants.slice(0, 5).map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground" title={p.email}>
<span dir="auto" className="truncate text-foreground" title={p.email}>
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">
@@ -575,7 +600,7 @@ export function EventDetailPopover({
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
<span dir="auto" className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
</div>
)}
@@ -583,7 +608,7 @@ export function EventDetailPopover({
{event.description && (
<div className="flex items-start gap-2.5">
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
<p dir="auto" className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
<LinkifiedText text={event.description} />
</p>
</div>
+15 -5
View File
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
import { Upload, Check, RefreshCw, Globe } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { getEventStartDate } from "@/lib/calendar-utils";
@@ -53,6 +52,9 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// The dialog opens on the drop zone, or on the address field for a link.
const dropZoneRef = useRef<HTMLButtonElement>(null);
const urlInputRef = useRef<HTMLInputElement>(null);
const targetId = useId();
const validateFile = useCallback((file: File): string | null => {
@@ -209,10 +211,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
if (!event.start) return "";
try {
const date = getEventStartDate(event as CalendarEvent);
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
return event.showWithoutTime
? format(date, "MMM d, yyyy")
: format(date, `MMM d, yyyy ${timeFmt}`);
// The locale's own order and month names, not an English pattern.
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "short",
day: "numeric",
year: "numeric",
...(event.showWithoutTime ? {} : timeFormat === "12h"
? { hour: "numeric", minute: "2-digit", hour12: true }
: { hour: "2-digit", minute: "2-digit", hour12: false }),
});
} catch {
return event.start;
}
@@ -229,6 +236,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
onClose={close}
title={t("title")}
size="full-on-phone"
initialFocus={importMode === "url" ? urlInputRef : dropZoneRef}
dirty={step !== "select" || urlInput.trim() !== (initialUrl ?? "")}
footer={step !== "importing" ? (
<>
@@ -281,6 +289,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{importMode === "file" && (
<>
<button
ref={dropZoneRef}
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
@@ -313,6 +322,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
<p className="text-sm text-muted-foreground">{t("url_description")}</p>
<div className="flex gap-2">
<input
ref={urlInputRef}
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
@@ -15,14 +15,23 @@ interface RecurrenceScopeDialogProps {
onClose: () => void;
/** A line under the question, e.g. that the participants get a cancellation. */
note?: string;
/** The series' title: a delete says which of its events go (R3). */
eventTitle?: string;
}
const DELETE_MESSAGES: Record<RecurrenceEditScope, string> = {
this: "delete_this_message",
this_and_future: "delete_following_message",
all: "delete_all_message",
};
export function RecurrenceScopeDialog({
isOpen,
actionType,
onSelect,
onClose,
note,
eventTitle = "",
}: RecurrenceScopeDialogProps) {
const t = useTranslations("calendar.recurrence_scope");
const id = useId();
@@ -72,10 +81,13 @@ export function RecurrenceScopeDialog({
</div>
<div>
<h2 id={`${id}-title`} className="text-lg font-semibold">
{isDelete ? t("delete_title") : isRsvp ? t("rsvp_title") : t("edit_title")}
{isDelete ? t("delete_question") : isRsvp ? t("rsvp_title") : t("edit_title")}
</h2>
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1">
{isRsvp ? t("rsvp_description") : t("description")}
{/* A delete says what goes with the chosen scope, and that it is for good. */}
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1" aria-live="polite">
{isDelete
? t(DELETE_MESSAGES[choice], { title: eventTitle })
: isRsvp ? t("rsvp_description") : t("description")}
</p>
{note && <p className="text-sm text-muted-foreground mt-1">{note}</p>}
</div>
+22 -6
View File
@@ -10,6 +10,10 @@ interface CalendarTaskChipProps {
calendar?: Calendar;
onToggleComplete?: (task: CalendarTask) => void;
onSelect?: (task: CalendarTask) => void;
/** A full row for a finger: 44px tall, a bigger circle with a 44px hit area (phone day view). */
touch?: boolean;
/** False for a day scrolled out of view: Tab skips it. */
tabbable?: boolean;
className?: string;
}
@@ -17,7 +21,7 @@ interface CalendarTaskChipProps {
* A task on its due day in the month and week grids (#1107). The circle
* toggles completion; the title opens the task in the editor.
*/
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, className }: CalendarTaskChipProps) {
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, touch = false, tabbable = true, className }: CalendarTaskChipProps) {
const t = useTranslations("calendar");
const isCompleted = task.progress === "completed";
const color = calendar?.color || "#3b82f6";
@@ -26,33 +30,45 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, c
return (
<div
data-calendar-task={task.id}
className={cn("h-full rounded text-[10px] font-medium flex items-center overflow-hidden", className)}
className={cn(
"rounded font-medium flex items-center overflow-hidden",
touch ? "min-h-11 text-xs" : "h-full text-[10px]",
className,
)}
style={{ backgroundColor: `${color}20`, borderInlineStart: `3px solid ${color}` }}
>
<button
type="button"
tabIndex={tabbable ? undefined : -1}
onClick={(e) => { e.stopPropagation(); onToggleComplete?.(task); }}
className="group h-full ps-1 pe-0.5 flex items-center flex-shrink-0"
className={cn(
"group flex items-center flex-shrink-0",
touch ? "self-stretch w-11 justify-center" : "h-full ps-1 pe-0.5",
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
<span
className={cn(
"w-2.5 h-2.5 rounded-full border flex items-center justify-center",
"rounded-full border flex items-center justify-center",
touch ? "w-4 h-4" : "w-2.5 h-2.5",
isCompleted ? "bg-success border-success text-success-foreground" : "group-hover:bg-background",
)}
style={isCompleted ? undefined : { borderColor: color }}
>
{isCompleted && <Check className="h-2 w-2" />}
{isCompleted && <Check className={touch ? "h-3 w-3" : "h-2 w-2"} />}
</span>
</button>
<button
type="button"
tabIndex={tabbable ? undefined : -1}
onClick={(e) => { e.stopPropagation(); onSelect?.(task); }}
className={cn(
"h-full flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
"flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
touch ? "self-stretch" : "h-full",
isCompleted && "line-through text-muted-foreground",
)}
title={title}
dir="auto"
>
{title}
</button>
+21 -6
View File
@@ -14,6 +14,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
import { taskToView } from "@/lib/plugin-projection";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { canCreateEventsIn } from "@/lib/calendar-editability";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -25,6 +26,8 @@ interface TaskModalProps {
/** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */
onDirtyChange?: (dirty: boolean) => void;
isMobile?: boolean;
/** Subscribed feeds are read-only: no tasks go there. */
isSubscriptionCalendar?: (calendarId: string) => boolean;
}
type PriorityLevel = "none" | "high" | "medium" | "low";
@@ -53,7 +56,8 @@ export function TaskModal({
onDelete,
onClose,
onDirtyChange,
isMobile: _isMobile,
isMobile = false,
isSubscriptionCalendar,
}: TaskModalProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
@@ -63,7 +67,9 @@ export function TaskModal({
// task-detail-section: the saved task, for an existing task only.
const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]);
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
// The calendars a task can go into: not read-only ones (Birthdays,
// feeds), the same rule as the event editor.
const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar));
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
const [title, setTitle] = useState(task?.title ?? "");
@@ -80,6 +86,8 @@ export function TaskModal({
}
return defaultCalendarId;
});
// A task already in a read-only calendar keeps it listed.
const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c));
const [alertOption, setAlertOption] = useState<AlertOption>(() => {
if (!task?.alerts) return "none";
const first = Object.values(task.alerts)[0];
@@ -205,10 +213,17 @@ export function TaskModal({
}, [requestClose, handleSave]);
return (
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
// The desktop side panel is a dialog named by its title, like the event
// editor; on a phone the full-screen wrapper around it is.
<div
role={isMobile ? undefined : "dialog"}
aria-labelledby={isMobile ? undefined : `${id}-heading`}
className="flex flex-col h-full bg-background"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-sm font-semibold">
<h2 id={`${id}-heading`} className="text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose}>
@@ -313,7 +328,7 @@ export function TaskModal({
</div>
{/* Calendar */}
{writableCalendars.length > 1 && (
{calendarOptions.length > 1 && (
<div className="space-y-2">
<label htmlFor={`${id}-calendar`} className="text-xs font-medium text-muted-foreground">
{t("tasks.calendar")}
@@ -324,7 +339,7 @@ export function TaskModal({
onChange={(e) => setCalendarId(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
>
{writableCalendars.map((cal) => (
{calendarOptions.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</select>
+11
View File
@@ -59,6 +59,17 @@ describe('Dialog', () => {
expect(document.activeElement).toBe(opener);
});
it('skips fields out of the tab order, like the hidden input behind a drop zone', () => {
render(
<Dialog isOpen onClose={() => {}} title="T">
<button type="button">drop zone</button>
<input type="file" tabIndex={-1} className="hidden" aria-label="file" />
<input aria-label="name" />
</Dialog>,
);
expect(document.activeElement).toBe(screen.getByLabelText('name'));
});
it('honours initialFocus', () => {
function Host() {
const target = useRef<HTMLButtonElement>(null);
+3 -1
View File
@@ -53,8 +53,10 @@ const SIZES: Record<DialogSize, string> = {
"max-w-2xl max-sm:max-w-none max-sm:h-full max-sm:max-h-none max-sm:rounded-none max-sm:border-0",
};
// A field out of the tab order (the hidden input behind a file drop zone)
// can't take the focus: picking it left the focus on the page behind.
const FIELD_SELECTOR =
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"]';
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([tabindex="-1"]):not(:disabled), textarea:not([tabindex="-1"]):not(:disabled), select:not([tabindex="-1"]):not(:disabled), [contenteditable="true"]';
/**
* The one dialog shell (R4): a portal on <body> at z-[60] over a dimmed
@@ -60,6 +60,17 @@ describe('useCalendarEventSearch (CAL-16)', () => {
expect(result.current?.past.map((e) => e.id)).toEqual(['old', 'older']);
});
it('shows a series under its own title when its next occurrence was renamed', () => {
const { client } = clientWith(() => new Promise(() => {}));
const series = [
makeEvent('sync-1', 'Sync edited', '2026-10-29T10:00:00', { uid: 'sync', recurrenceId: '2026-10-29T10:00:00' }),
makeEvent('sync-2', 'Sync', '2026-11-05T10:00:00', { uid: 'sync', recurrenceId: '2026-11-05T10:00:00' }),
makeEvent('sync-3', 'Sync', '2026-11-12T10:00:00', { uid: 'sync', recurrenceId: '2026-11-12T10:00:00' }),
];
const { result } = renderHook(() => useCalendarEventSearch('sync', client, series));
expect(result.current?.upcoming.map((e) => e.id)).toEqual(['sync-1', 'sync-2']);
});
it('keeps the loaded matches when the server can not search', async () => {
const { client } = clientWith(async () => { throw new Error('unsupportedFilter'); });
const { result } = renderHook(() => useCalendarEventSearch('review', client, loaded));
@@ -0,0 +1,35 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
// German dates on the event card read "Sa, Okt 31, 2026": English order
// with translated names. Each locale now gets its own order.
let locale = 'en';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => locale,
}));
import { useFormatEventDate } from '../use-format-event-date';
function formatIn(code: string, date: Date) {
locale = code;
const { result } = renderHook(() => useFormatEventDate());
return result.current(date);
}
describe('useFormatEventDate', () => {
const date = new Date(2026, 9, 31);
it('keeps the English order in English', () => {
expect(formatIn('en', date)).toBe('Sat, Oct 31, 2026');
});
it('puts the day before the month in German', () => {
expect(formatIn('de', date)).toBe('Sa., 31. Okt. 2026');
});
it('follows French too', () => {
expect(formatIn('fr', date)).toBe('sam. 31 oct. 2026');
});
});
@@ -0,0 +1,43 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useHoverPreviewGate } from '../use-hover-preview-gate';
// The hover preview opened on top of the open editor, and by itself after
// a dialog closed with the pointer resting on an event.
function move(x: number, y: number) {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: x, clientY: y }));
}
describe('useHoverPreviewGate', () => {
it('lets a preview open when nothing is open', () => {
const { result } = renderHook(() => useHoverPreviewGate(false));
expect(result.current()).toBe(true);
});
it('holds previews back while an editor is open, and after it closed until the pointer moves', () => {
const { result, rerender } = renderHook(({ open }) => useHoverPreviewGate(open), { initialProps: { open: true } });
move(100, 100);
expect(result.current()).toBe(false);
rerender({ open: false });
expect(result.current()).toBe(false);
move(101, 101);
expect(result.current()).toBe(false);
move(140, 120);
expect(result.current()).toBe(true);
});
it('holds previews back while a modal dialog is on the page, and after it closed', async () => {
const { result } = renderHook(() => useHoverPreviewGate(false));
move(10, 10);
const dialog = document.createElement('div');
dialog.setAttribute('aria-modal', 'true');
document.body.appendChild(dialog);
expect(result.current()).toBe(false);
dialog.remove();
await new Promise((r) => setTimeout(r, 0));
expect(result.current()).toBe(false);
move(60, 60);
expect(result.current()).toBe(true);
});
});
+5 -2
View File
@@ -22,8 +22,11 @@ export interface CalendarSearchResults {
}
// One row per event: a series found as loaded occurrences and as the
// server's base event shows once.
const seriesKey = (event: CalendarEvent) => `${event.accountId ?? ""} ${event.uid || event.originalId || event.id}`;
// server's base event shows once. An occurrence renamed on its own gets a
// row of its own, so the series still shows under its own title: the next
// occurrence being the renamed one hid the series.
const seriesKey = (event: CalendarEvent) =>
`${event.accountId ?? ""} ${event.uid || event.originalId || event.id} ${event.title ?? ""}`;
/**
* Searches the user's events by title, place, description and participants
+16
View File
@@ -200,6 +200,21 @@ export function useCalendarLocale() {
});
};
/**
* Short day label for a phone's title ("Oct 28", "28. Okt."). The weekday
* is in the day's header right under it, and with it German no longer fit.
*/
const formatDayShort = (date: Date): string => {
if (isJalali) {
const { jm, jd } = toJalali(date);
return `${jd} ${JALALI_MONTHS[jm - 1]}`;
}
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
month: "short",
day: "numeric",
});
};
// ------------------------------------------------------------------
// Calendar-system-aware month/year getters (for navigation, etc.)
// All return values use **0-based** months to stay compatible with
@@ -244,6 +259,7 @@ export function useCalendarLocale() {
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
formatDayShort,
getMonth,
getYear,
monthLabelKeys,
+12 -13
View File
@@ -4,13 +4,10 @@ import { format } from "date-fns";
import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils";
/**
* Returns a memoized function that formats a calendar event date
* using the current locale for day and month names.
*
* The string will be in the format: "EEE, MMM d, yyyy"
*
* For example: "Wed, Apr 29, 2026" (en)
* "Qua, Abr 29, 2026" (pt)
* Returns a memoized function that formats a calendar event date in the
* current locale's own order and names (Intl), e.g.
* "Wed, Apr 29, 2026" (en)
* "Mi., 29. Apr. 2026" (de)
*
* When the Jalali calendar is active (fa locale), the format uses
* Persian day/month names with the Jalali year, e.g.:
@@ -31,12 +28,14 @@ export function useFormatEventDate(): (date: Date) => string {
return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`;
}
const dayOfWeek = format(date, "EEE").toLowerCase();
const month = format(date, "MMM").toLowerCase();
const day = format(date, "d");
const year = format(date, "yyyy");
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
// A display date: its local fields are the wall-clock to show.
return date.toLocaleDateString(locale === "en" ? "en-US" : locale, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
},
[t, isJalali]
[t, isJalali, locale]
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef } from "react";
import { isContextMenuOpen } from "@/components/ui/context-menu";
/** How far the pointer has to move before a preview may open again. */
const MOVE_THRESHOLD = 4;
const MODAL_SELECTOR = '[aria-modal="true"]';
/**
* Decides whether a hover preview may open. Not while an editor, a card,
* a menu or a dialog is open (`overlayOpen` plus any modal on the page),
* and not after one closed until the pointer really moves: the pointer
* resting where the dialog was otherwise "hovers" the event under it and
* the preview opens by itself.
*/
export function useHoverPreviewGate(overlayOpen: boolean): () => boolean {
const overlayOpenRef = useRef(overlayOpen);
const pointer = useRef<{ x: number; y: number } | null>(null);
// Where the pointer was when previews were held back; null when free.
const heldAt = useRef<{ x: number; y: number } | null>(null);
const hold = useCallback(() => {
heldAt.current = pointer.current ?? { x: Number.NaN, y: Number.NaN };
}, []);
useEffect(() => {
const onMove = (e: PointerEvent | MouseEvent) => {
const at = { x: e.clientX, y: e.clientY };
pointer.current = at;
const held = heldAt.current;
if (!held) return;
// Position unknown when it was held: the first move frees it.
if (Number.isNaN(held.x) || Math.hypot(at.x - held.x, at.y - held.y) > MOVE_THRESHOLD) {
heldAt.current = null;
}
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => window.removeEventListener("pointermove", onMove);
}, []);
// An editor, card or quick editor of the calendar closing.
useEffect(() => {
const was = overlayOpenRef.current;
overlayOpenRef.current = overlayOpen;
if (was && !overlayOpen) hold();
}, [overlayOpen, hold]);
// Any other dialog (a confirm, import, shortcuts) portalled onto <body>.
useEffect(() => {
if (typeof MutationObserver === "undefined") return;
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.removedNodes) {
if (node instanceof Element && (node.matches(MODAL_SELECTOR) || node.querySelector(MODAL_SELECTOR))) {
hold();
return;
}
}
}
});
observer.observe(document.body, { childList: true });
return () => observer.disconnect();
}, [hold]);
return useCallback(() => {
if (overlayOpenRef.current || isContextMenuOpen() || document.querySelector(MODAL_SELECTOR)) {
hold();
return false;
}
return heldAt.current === null;
}, [hold]);
}
+19 -7
View File
@@ -17,6 +17,11 @@ interface UseTimeGridFocusOptions {
selectedDate: Date;
/** Creates an event at the slot (Enter, `n` or `c`). */
onCreate: (start: Date) => void;
/**
* Whether a day is scrolled into view. The tab stop stays on a day in
* view: one in a hidden week made Tab scroll the grid off its week.
*/
isInView?: (key: string) => boolean;
}
export interface TimeGridSlotProps {
@@ -27,6 +32,7 @@ export interface TimeGridSlotProps {
}
const slotId = (key: string, hour: number) => `${key}T${hour}`;
const everyDay = () => true;
/**
* Keyboard access to the empty slots of the week and day grids (CAL-13,
@@ -35,7 +41,7 @@ const slotId = (key: string, hour: number) => `${key}T${hour}`;
* event there. The month grid works the same way with days.
*/
export function useTimeGridFocus({
rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate,
rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate, isInView = everyDay,
}: UseTimeGridFocusOptions) {
const firstHour = hours.startMinutes / 60;
const lastHour = hours.endMinutes / 60 - 1;
@@ -43,17 +49,23 @@ export function useTimeGridFocus({
const dayIndexByKey = useMemo(() => new Map(days.map((day, index) => [dayKey(day), index])), [days]);
// The slot that takes Tab: the last one focused while it is still drawn,
// else the selected day (or the first loaded one) at the current hour.
// The slot that takes Tab: the last one focused while it is still drawn
// and in view, else the selected day (or the first day in view, else the
// first loaded one) at the current hour.
const tabStop = useMemo(() => {
if (focused && dayIndexByKey.has(focused.key) && focused.hour >= firstHour && focused.hour <= lastHour) {
if (focused && dayIndexByKey.has(focused.key) && isInView(focused.key) && focused.hour >= firstHour && focused.hour <= lastHour) {
return focused;
}
const selectedKey = dayKey(selectedDate);
const key = dayIndexByKey.has(selectedKey) ? selectedKey : days[0] ? dayKey(days[0]) : selectedKey;
const hour = Math.min(lastHour, Math.max(firstHour, displayNow().getHours()));
const firstInView = days.map(dayKey).find(isInView);
const key = dayIndexByKey.has(selectedKey) && isInView(selectedKey)
? selectedKey
: firstInView ?? (days[0] ? dayKey(days[0]) : selectedKey);
const hour = focused && focused.hour >= firstHour && focused.hour <= lastHour
? focused.hour
: Math.min(lastHour, Math.max(firstHour, displayNow().getHours()));
return { key, hour };
}, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days]);
}, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days, isInView]);
const focusSlot = useCallback((key: string, hour: number) => {
const root = rootRef.current;
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "تعديل حدث متكرر",
"delete_title": "حذف حدث متكرر",
"delete_question": "حذف الحدث المتكرر؟",
"delete_this_message": "سيتم حذف هذا التكرار فقط من \"{title}\". لا يمكن التراجع عن ذلك.",
"delete_following_message": "سيتم حذف هذا التكرار وكل التكرارات التالية من \"{title}\". لا يمكن التراجع عن ذلك.",
"delete_all_message": "سيتم حذف كل تكرارات \"{title}\". لا يمكن التراجع عن ذلك.",
"description": "هذا حدث متكرر. ما الأحداث التي تريد تعديلها؟",
"this_event": "هذا الحدث فقط",
"this_and_future": "هذا الحدث والأحداث التالية",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Edita l'esdeveniment periòdic",
"delete_title": "Suprimeix l'esdeveniment periòdic",
"delete_question": "Voleu suprimir l'esdeveniment periòdic?",
"delete_this_message": "Només se suprimirà aquesta repetició de «{title}». No es pot desfer.",
"delete_following_message": "Se suprimiran aquesta repetició i totes les següents de «{title}». No es pot desfer.",
"delete_all_message": "Se suprimiran totes les repeticions de «{title}». No es pot desfer.",
"description": "Aquest és un esdeveniment periòdic. Quins esdeveniments voleu modificar?",
"this_event": "Només aquest esdeveniment",
"this_and_future": "Aquest i els següents esdeveniments",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Upravit opakující se událost",
"delete_title": "Odstranit opakující se událost",
"delete_question": "Odstranit opakující se událost?",
"delete_this_message": "Odstraní se pouze tento výskyt události \"{title}\". Tuto akci nelze vrátit.",
"delete_following_message": "Odstraní se tento a všechny další výskyty události \"{title}\". Tuto akci nelze vrátit.",
"delete_all_message": "Odstraní se všechny výskyty události \"{title}\". Tuto akci nelze vrátit.",
"description": "Toto je opakující se událost. Které události chcete upravit?",
"this_event": "Pouze tuto událost",
"this_and_future": "Tuto a všechny následující události",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Redigér tilbagevendende begivenhed",
"delete_title": "Slet tilbagevendende begivenhed",
"delete_question": "Slet tilbagevendende begivenhed?",
"delete_this_message": "Kun denne forekomst af \"{title}\" bliver slettet. Det kan ikke fortrydes.",
"delete_following_message": "Denne og alle senere forekomster af \"{title}\" bliver slettet. Det kan ikke fortrydes.",
"delete_all_message": "Alle forekomster af \"{title}\" bliver slettet. Det kan ikke fortrydes.",
"description": "Dette er en tilbagevendende begivenhed. Hvilke begivenheder vil du ændre?",
"this_event": "Kun denne begivenhed",
"this_and_future": "Denne og fremtidige begivenheder",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Wiederkehrendes Ereignis bearbeiten",
"delete_title": "Wiederkehrendes Ereignis löschen",
"delete_question": "Wiederkehrenden Termin löschen?",
"delete_this_message": "Nur dieser Termin von „{title}“ wird gelöscht. Das kann nicht rückgängig gemacht werden.",
"delete_following_message": "Dieser und alle folgenden Termine von „{title}“ werden gelöscht. Das kann nicht rückgängig gemacht werden.",
"delete_all_message": "Alle Termine von „{title}“ werden gelöscht. Das kann nicht rückgängig gemacht werden.",
"description": "Dies ist ein wiederkehrendes Ereignis. Welche Ereignisse möchten Sie ändern?",
"this_event": "Nur dieses Ereignis",
"this_and_future": "Dieses und folgende Ereignisse",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Edit recurring event",
"delete_title": "Delete recurring event",
"delete_question": "Delete recurring event?",
"delete_this_message": "Only this occurrence of \"{title}\" will be deleted. This can't be undone.",
"delete_following_message": "This and every later occurrence of \"{title}\" will be deleted. This can't be undone.",
"delete_all_message": "Every occurrence of \"{title}\" will be deleted. This can't be undone.",
"description": "This is a recurring event. Which events would you like to modify?",
"this_event": "This event only",
"this_and_future": "This and following events",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Editar evento recurrente",
"delete_title": "Eliminar evento recurrente",
"delete_question": "¿Eliminar evento recurrente?",
"delete_this_message": "Solo se eliminará esta repetición de \"{title}\". No se puede deshacer.",
"delete_following_message": "Se eliminarán esta repetición y todas las siguientes de \"{title}\". No se puede deshacer.",
"delete_all_message": "Se eliminarán todas las repeticiones de \"{title}\". No se puede deshacer.",
"description": "Este es un evento recurrente. ¿Qué eventos desea modificar?",
"this_event": "Solo este evento",
"this_and_future": "Este evento y los siguientes",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Korduva sündmuse muutmine",
"delete_title": "Korduva sündmuse kustutamine",
"delete_question": "Kas kustutada korduv sündmus?",
"delete_this_message": "Kustutatakse ainult see \"{title}\" kordus. Seda ei saa tagasi võtta.",
"delete_following_message": "Kustutatakse see ja kõik järgmised \"{title}\" kordused. Seda ei saa tagasi võtta.",
"delete_all_message": "Kustutatakse kõik \"{title}\" kordused. Seda ei saa tagasi võtta.",
"description": "See on korduv sündmus. Milliseid sündmusi soovid muuta?",
"this_event": "Ainult see sündmus",
"this_and_future": "See ja järgnevad sündmused",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "ویرایش رویداد تکراری",
"delete_title": "حذف رویداد تکراری",
"delete_question": "رویداد تکراری حذف شود؟",
"delete_this_message": "فقط همین تکرارِ \"{title}\" حذف خواهد شد. این کار قابل بازگشت نیست.",
"delete_following_message": "این تکرار و همه تکرارهای بعدیِ \"{title}\" حذف خواهند شد. این کار قابل بازگشت نیست.",
"delete_all_message": "همه تکرارهای \"{title}\" حذف خواهند شد. این کار قابل بازگشت نیست.",
"description": "این یک رویداد تکراری است. کدام رویدادها را می‌خواهید تغییر دهید؟",
"this_event": "فقط این رویداد",
"this_and_future": "این رویداد و رویدادهای بعدی",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Modifier l'événement récurrent",
"delete_title": "Supprimer l'événement récurrent",
"delete_question": "Supprimer l'événement récurrent ?",
"delete_this_message": "Seule cette occurrence de « {title} » sera supprimée. Cette action est irréversible.",
"delete_following_message": "Cette occurrence et toutes les suivantes de « {title} » seront supprimées. Cette action est irréversible.",
"delete_all_message": "Toutes les occurrences de « {title} » seront supprimées. Cette action est irréversible.",
"description": "Ceci est un événement récurrent. Quels événements souhaitez-vous modifier ?",
"this_event": "Cet événement uniquement",
"this_and_future": "Cet événement et les suivants",
+4
View File
@@ -3692,6 +3692,10 @@
"recurrence_scope": {
"edit_title": "ערוך אירוע חוזר",
"delete_title": "מחק אירוע חוזר",
"delete_question": "למחוק את האירוע החוזר?",
"delete_this_message": "רק המופע הזה של \"{title}\" יימחק. לא ניתן לבטל פעולה זו.",
"delete_following_message": "המופע הזה וכל המופעים הבאים של \"{title}\" יימחקו. לא ניתן לבטל פעולה זו.",
"delete_all_message": "כל המופעים של \"{title}\" יימחקו. לא ניתן לבטל פעולה זו.",
"description": "זהו אירוע שחוזר על עצמו. אילו אירועים תרצה לשנות?",
"this_event": "אירוע זה בלבד",
"this_and_future": "זה והאירועים הבאים",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Ismétlődő esemény szerkesztése",
"delete_title": "Ismétlődő esemény törlése",
"delete_question": "Törli az ismétlődő eseményt?",
"delete_this_message": "Csak a(z) \"{title}\" esemény ezen alkalma törlődik. Ez nem vonható vissza.",
"delete_following_message": "A(z) \"{title}\" esemény ezen és minden későbbi alkalma törlődik. Ez nem vonható vissza.",
"delete_all_message": "A(z) \"{title}\" esemény minden alkalma törlődik. Ez nem vonható vissza.",
"description": "Ez egy ismétlődő esemény. Mely eseményeket szeretnéd módosítani?",
"this_event": "Csak ez az esemény",
"this_and_future": "Ez és a következő események",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Modifica evento ricorrente",
"delete_title": "Elimina evento ricorrente",
"delete_question": "Eliminare l'evento ricorrente?",
"delete_this_message": "Verrà eliminata solo questa occorrenza di \"{title}\". L'operazione non può essere annullata.",
"delete_following_message": "Verranno eliminate questa occorrenza e tutte le successive di \"{title}\". L'operazione non può essere annullata.",
"delete_all_message": "Verranno eliminate tutte le occorrenze di \"{title}\". L'operazione non può essere annullata.",
"description": "Questo è un evento ricorrente. Quali eventi desideri modificare?",
"this_event": "Solo questo evento",
"this_and_future": "Questo evento e i seguenti",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "繰り返しイベントを編集",
"delete_title": "繰り返しイベントを削除",
"delete_question": "繰り返しの予定を削除しますか?",
"delete_this_message": "「{title}」のこの回だけを削除します。元に戻すことはできません。",
"delete_following_message": "「{title}」のこの回以降をすべて削除します。元に戻すことはできません。",
"delete_all_message": "「{title}」のすべての回を削除します。元に戻すことはできません。",
"description": "これは繰り返しイベントです。どのイベントを変更しますか?",
"this_event": "このイベントのみ",
"this_and_future": "これ以降のすべてのイベント",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "반복 일정 수정",
"delete_title": "반복 일정 삭제",
"delete_question": "반복 일정을 삭제할까요?",
"delete_this_message": "\"{title}\"의 이번 일정만 삭제됩니다. 되돌릴 수 없습니다.",
"delete_following_message": "\"{title}\"의 이번 일정과 이후 모든 일정이 삭제됩니다. 되돌릴 수 없습니다.",
"delete_all_message": "\"{title}\"의 모든 일정이 삭제됩니다. 되돌릴 수 없습니다.",
"description": "반복되는 일정이에요. 어떤 일정을 수정할까요?",
"this_event": "이 일정만",
"this_and_future": "이 일정 및 향후 일정 모두",
+4
View File
@@ -3773,6 +3773,10 @@
"recurrence_scope": {
"edit_title": "Rediģēt atkārtotu pasākumu",
"delete_title": "Dzēst atkārtotu pasākumu",
"delete_question": "Dzēst atkārtotu notikumu?",
"delete_this_message": "Tiks dzēsta tikai šī «{title}» reize. To nevar atsaukt.",
"delete_following_message": "Tiks dzēsta šī un visas nākamās «{title}» reizes. To nevar atsaukt.",
"delete_all_message": "Tiks dzēstas visas «{title}» reizes. To nevar atsaukt.",
"description": "Šis ir atkārtots pasākums. Kuras instances vēlaties mainīt?",
"this_event": "Tikai šo pasākumu",
"this_and_future": "Šo un turpmākos",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Давтах үйл явдлыг засах",
"delete_title": "Дахин давтагдах үйл явдлыг устгах",
"delete_question": "Давтагдах арга хэмжээг устгах уу?",
"delete_this_message": "\"{title}\"-ийн зөвхөн энэ удаагийнх устгагдана. Үүнийг буцаах боломжгүй.",
"delete_following_message": "\"{title}\"-ийн энэ болон дараагийн бүх удаагийнх устгагдана. Үүнийг буцаах боломжгүй.",
"delete_all_message": "\"{title}\"-ийн бүх удаагийнх устгагдана. Үүнийг буцаах боломжгүй.",
"description": "Энэ бол давтагдах үйл явдал. Та ямар үйл явдлыг өөрчлөхийг хүсэж байна вэ?",
"this_event": "Зөвхөн энэ арга хэмжээ",
"this_and_future": "Энэ болон дараах үйл явдлууд",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Rediger gjentakende hendelse",
"delete_title": "Slett gjentakende hendelse",
"delete_question": "Vil du slette den gjentakende hendelsen?",
"delete_this_message": "Bare denne forekomsten av «{title}» blir slettet. Dette kan ikke angres.",
"delete_following_message": "Denne og alle senere forekomster av «{title}» blir slettet. Dette kan ikke angres.",
"delete_all_message": "Alle forekomster av «{title}» blir slettet. Dette kan ikke angres.",
"description": "Dette er en gjentakende hendelse. Hvilke hendelser vil du endre?",
"this_event": "Bare denne hendelsen",
"this_and_future": "Dette og følgende hendelser",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Terugkerend evenement bewerken",
"delete_title": "Terugkerend evenement verwijderen",
"delete_question": "Terugkerende afspraak verwijderen?",
"delete_this_message": "Alleen deze keer van \"{title}\" wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
"delete_following_message": "Deze en alle volgende keren van \"{title}\" worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
"delete_all_message": "Alle keren van \"{title}\" worden verwijderd. Dit kan niet ongedaan worden gemaakt.",
"description": "Dit is een terugkerend evenement. Welke evenementen wilt u wijzigen?",
"this_event": "Alleen dit evenement",
"this_and_future": "Dit en volgende evenementen",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Edytuj wydarzenie cykliczne",
"delete_title": "Usuń wydarzenie cykliczne",
"delete_question": "Usunąć wydarzenie cykliczne?",
"delete_this_message": "Zostanie usunięte tylko to wystąpienie \"{title}\". Tej operacji nie można cofnąć.",
"delete_following_message": "Zostaną usunięte to i wszystkie następne wystąpienia \"{title}\". Tej operacji nie można cofnąć.",
"delete_all_message": "Zostaną usunięte wszystkie wystąpienia \"{title}\". Tej operacji nie można cofnąć.",
"description": "To jest wydarzenie cykliczne. Które wydarzenia chcesz zmodyfikować?",
"this_event": "Tylko to wydarzenie",
"this_and_future": "To i następne wydarzenia",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Editar evento recorrente",
"delete_title": "Excluir evento recorrente",
"delete_question": "Excluir evento recorrente?",
"delete_this_message": "Somente esta ocorrência de \"{title}\" será excluída. Isso não pode ser desfeito.",
"delete_following_message": "Esta e todas as ocorrências seguintes de \"{title}\" serão excluídas. Isso não pode ser desfeito.",
"delete_all_message": "Todas as ocorrências de \"{title}\" serão excluídas. Isso não pode ser desfeito.",
"description": "Este é um evento recorrente. Quais eventos você deseja modificar?",
"this_event": "Apenas este evento",
"this_and_future": "Este evento e os seguintes",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Editați evenimentul recurent",
"delete_title": "Șterge evenimentul recurent",
"delete_question": "Ștergeți evenimentul recurent?",
"delete_this_message": "Va fi ștearsă doar această apariție a „{title}”. Acțiunea nu poate fi anulată.",
"delete_following_message": "Vor fi șterse această apariție și toate cele următoare ale „{title}”. Acțiunea nu poate fi anulată.",
"delete_all_message": "Vor fi șterse toate aparițiile „{title}”. Acțiunea nu poate fi anulată.",
"description": "Acesta este un eveniment recurent. Ce evenimente doriți să modificați?",
"this_event": "Numai acest eveniment",
"this_and_future": "Acest eveniment și evenimentele următoare",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Редактировать повторяющееся событие",
"delete_title": "Удалить повторяющееся событие",
"delete_question": "Удалить повторяющееся событие?",
"delete_this_message": "Будет удалено только это повторение «{title}». Это действие нельзя отменить.",
"delete_following_message": "Будут удалены это и все следующие повторения «{title}». Это действие нельзя отменить.",
"delete_all_message": "Будут удалены все повторения «{title}». Это действие нельзя отменить.",
"description": "Это повторяющееся событие. Какие события вы хотите изменить?",
"this_event": "Только это событие",
"this_and_future": "Это и последующие события",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Upraviť opakujúcu sa udalosť",
"delete_title": "Odstrániť opakujúcu sa udalosť",
"delete_question": "Odstrániť opakujúcu sa udalosť?",
"delete_this_message": "Odstráni sa iba tento výskyt udalosti \"{title}\". Túto akciu nemožno vrátiť.",
"delete_following_message": "Odstráni sa tento a všetky ďalšie výskyty udalosti \"{title}\". Túto akciu nemožno vrátiť.",
"delete_all_message": "Odstránia sa všetky výskyty udalosti \"{title}\". Túto akciu nemožno vrátiť.",
"description": "Toto je opakujúca sa udalosť. Ktoré udalosti chcete upraviť?",
"this_event": "Iba túto udalosť",
"this_and_future": "Túto a všetky nasledujúce udalosti",
+4
View File
@@ -2898,6 +2898,10 @@
"recurrence_scope": {
"edit_title": "แก้ไขกิจกรรมที่เกิดซ้ำ",
"delete_title": "ลบกิจกรรมที่เกิดซ้ำ",
"delete_question": "ลบกิจกรรมที่เกิดซ้ำหรือไม่?",
"delete_this_message": "จะลบเฉพาะครั้งนี้ของ “{title}” และไม่สามารถเรียกคืนได้",
"delete_following_message": "จะลบครั้งนี้และทุกครั้งถัดไปของ “{title}” และไม่สามารถเรียกคืนได้",
"delete_all_message": "จะลบทุกครั้งของ “{title}” และไม่สามารถเรียกคืนได้",
"description": "นี่คือกิจกรรมที่เกิดซ้ำ คุณต้องการปรับเปลี่ยนกิจกรรมใดบ้าง?",
"this_event": "เฉพาะกิจกรรมนี้",
"this_and_future": "กิจกรรมนี้และกิจกรรมที่ตามมา",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Yinelenen etkinliği düzenle",
"delete_title": "Yinelenen etkinliği sil",
"delete_question": "Yinelenen etkinlik silinsin mi?",
"delete_this_message": "\"{title}\" etkinliğinin yalnızca bu tekrarı silinecek. Bu işlem geri alınamaz.",
"delete_following_message": "\"{title}\" etkinliğinin bu ve sonraki tüm tekrarları silinecek. Bu işlem geri alınamaz.",
"delete_all_message": "\"{title}\" etkinliğinin tüm tekrarları silinecek. Bu işlem geri alınamaz.",
"description": "Bu yinelenen bir etkinlik. Hangi etkinlikleri değiştirmek istersiniz?",
"this_event": "Yalnızca bu etkinlik",
"this_and_future": "Bu ve sonraki etkinlikler",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "Редагувати повторювану подію",
"delete_title": "Видалити повторювану подію",
"delete_question": "Видалити повторювану подію?",
"delete_this_message": "Буде видалено лише це повторення «{title}». Цю дію не можна скасувати.",
"delete_following_message": "Буде видалено це й усі наступні повторення «{title}». Цю дію не можна скасувати.",
"delete_all_message": "Буде видалено всі повторення «{title}». Цю дію не можна скасувати.",
"description": "Це повторювана подія. Які події ви б хотіли змінити?",
"this_event": "Тільки ця подія",
"this_and_future": "Ця та наступні події",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "編輯重複活動",
"delete_title": "刪除重複活動",
"delete_question": "要刪除重複活動嗎?",
"delete_this_message": "僅刪除「{title}」的這一次,且無法復原。",
"delete_following_message": "將刪除「{title}」的這一次及之後的所有重複,且無法復原。",
"delete_all_message": "將刪除「{title}」的所有重複,且無法復原。",
"description": "這是重複活動。您要修改哪些活動?",
"this_event": "僅限本次活動",
"this_and_future": "本次及後續活動",
+4
View File
@@ -3774,6 +3774,10 @@
"recurrence_scope": {
"edit_title": "编辑重复事件",
"delete_title": "删除重复事件",
"delete_question": "删除重复事件?",
"delete_this_message": "仅删除“{title}”的这一次,且无法撤销。",
"delete_following_message": "将删除“{title}”的这一次及之后的所有重复,且无法撤销。",
"delete_all_message": "将删除“{title}”的所有重复,且无法撤销。",
"description": "这是一个重复事件。您想修改哪些事件?",
"this_event": "仅限本次活动",
"this_and_future": "本次及后续活动",