mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge branch 'ux/calendar3'
This commit is contained in:
@@ -126,6 +126,18 @@ describe('CalendarApp keyboard shortcuts', () => {
|
||||
fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' });
|
||||
expect(useCalendarStore.getState().viewMode).toBe('month');
|
||||
});
|
||||
|
||||
// New 6: the preview opened on top of the open editor.
|
||||
it('opens no hover preview while an editor is open', async () => {
|
||||
setUp([makeEvent(), makeEvent({ id: 'e2', uid: 'u2', title: 'Retro', start: '2026-10-13T14:00:00' })]);
|
||||
renderApp();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'events.edit' }));
|
||||
await screen.findByRole('dialog', { name: 'events.edit' });
|
||||
fireEvent.mouseEnter(screen.getAllByRole('button', { name: /Retro/ })[0]);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.queryByRole('dialog', { name: 'Retro' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -340,6 +352,18 @@ describe('CalendarApp event search (CAL-16)', () => {
|
||||
expect(await screen.findByText('search.no_results', {}, { timeout: 3000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers Clear search when nothing matches, back to the calendar with the cursor in the box', async () => {
|
||||
renderApp();
|
||||
const box = screen.getByRole('searchbox', { name: 'search.title' });
|
||||
fireEvent.change(box, { target: { value: 'zzz' } });
|
||||
await screen.findByText('search.no_results', {}, { timeout: 3000 });
|
||||
const results = screen.getByTestId('calendar-search-results');
|
||||
fireEvent.click(within(results).getByRole('button', { name: 'search.clear' }));
|
||||
expect(box).toHaveValue('');
|
||||
expect(document.activeElement).toBe(box);
|
||||
await waitFor(() => expect(screen.queryByTestId('calendar-search-results')).toBeNull());
|
||||
});
|
||||
|
||||
it('is in the command palette', () => {
|
||||
render(<><CalendarApp /><PaletteGroups /></>);
|
||||
const items = screen.getByTestId('palette').textContent ?? '';
|
||||
@@ -378,7 +402,7 @@ describe('CalendarApp deleting', () => {
|
||||
renderApp();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' }));
|
||||
expect(await screen.findByRole('dialog', { name: 'delete_title' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('dialog', { name: 'delete_question' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CalendarDayView } from '../calendar-day-view';
|
||||
import type { Calendar, CalendarEvent, CalendarTask } from '@/lib/jmap/types';
|
||||
|
||||
// CAL-27 follow-up: the phone day view drew tasks as 10px chips with a
|
||||
// circle too small to tap, and a busy neighbouring day left an empty
|
||||
// all-day block of ~150px above the hours of a day with none.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
const holiday = {
|
||||
id: 'ev-1', calendarIds: { 'cal-1': true }, title: 'Holiday', start: '2026-09-10T00:00:00',
|
||||
duration: 'P1D', showWithoutTime: true,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const task = {
|
||||
id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { 'cal-1': true },
|
||||
progress: 'needs-action', priority: 0, due: '2026-09-09', showWithoutTime: true,
|
||||
} as unknown as CalendarTask;
|
||||
|
||||
function renderDay(isMobile: boolean, tasks: CalendarTask[] = [task]) {
|
||||
return render(
|
||||
<CalendarDayView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="day:2026-09-08"
|
||||
rangeStart={new Date(2026, 8, 8)}
|
||||
rangeEnd={new Date(2026, 8, 10)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={[holiday]}
|
||||
calendars={calendars}
|
||||
tasks={tasks}
|
||||
onSelectEvent={vi.fn()}
|
||||
onCreateAtTime={vi.fn()}
|
||||
isMobile={isMobile}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('day view on a phone', () => {
|
||||
it('gives each task a full row with a 44px target for the circle', () => {
|
||||
renderDay(true);
|
||||
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
|
||||
expect(chip).toHaveClass('min-h-11', 'text-xs');
|
||||
expect(within(chip).getByRole('button', { name: /mark_complete|Mark as complete/i })).toHaveClass('w-11', 'self-stretch');
|
||||
});
|
||||
|
||||
it('keeps the small shared chip on a wide screen', () => {
|
||||
renderDay(false);
|
||||
const chip = document.querySelector<HTMLElement>('[data-calendar-task="t1"]')!;
|
||||
expect(chip).toHaveClass('h-full', 'text-[10px]');
|
||||
});
|
||||
|
||||
it('sizes the all-day row by the day in view, not by a busy neighbour', () => {
|
||||
renderDay(true, []);
|
||||
const today = document.querySelector<HTMLElement>('[data-day="2026-09-09"]')!;
|
||||
const next = document.querySelector<HTMLElement>('[data-day="2026-09-10"]')!;
|
||||
// The day in view has nothing all-day: no empty block under its date.
|
||||
expect(within(today).queryByText(/all_day|All day/i)).toBeNull();
|
||||
expect(today).not.toHaveClass('h-0');
|
||||
// The neighbour's all-day event takes no height of its own.
|
||||
expect(within(next).getByRole('button', { name: /Holiday/ })).toBeInTheDocument();
|
||||
expect(next).toHaveClass('h-0', 'min-h-full', 'overflow-hidden');
|
||||
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,16 @@ describe('import dialog from the keyboard', () => {
|
||||
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// It focused the hidden file input, which can't take the focus, so the
|
||||
// focus stayed on the page behind and Tab walked the calendar.
|
||||
it('opens with the focus on the drop zone, or on the address field for a link', () => {
|
||||
const { unmount } = render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: /select_file/ }));
|
||||
unmount();
|
||||
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} initialUrl="https://example.com/a.ics" />);
|
||||
expect(document.activeElement).toBe(screen.getByLabelText('tab_url'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('import preview', () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventCard } from '../event-card';
|
||||
import { CalendarSearchResults } from '../calendar-search';
|
||||
import { CalendarAgendaView } from '../calendar-agenda-view';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// In Arabic, an English title took the page's direction (".Dinner with
|
||||
// Carlos and co", cut at the start) and "09:15 – 09:30" read "09:30 – 09:15".
|
||||
|
||||
vi.mock('@/lib/timezone', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/timezone')>()),
|
||||
displayNow: () => new Date(2026, 9, 28, 8, 0),
|
||||
}));
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
const dinner = {
|
||||
id: 'e1', '@type': 'Event', uid: 'e1', title: 'Dinner with Carlos and co.', start: '2026-10-28T09:15:00',
|
||||
duration: 'PT1H', showWithoutTime: false, status: 'confirmed', calendarIds: { 'cal-1': true },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const isolatedLtr = (el: HTMLElement) => el.closest('bdi')?.getAttribute('dir') === 'ltr';
|
||||
|
||||
describe('user text and times in a right-to-left layout', () => {
|
||||
it.each(['chip', 'span', 'block'] as const)('gives the event title its own direction (%s)', (variant) => {
|
||||
render(<EventCard event={dinner} variant={variant} />);
|
||||
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
|
||||
});
|
||||
|
||||
it('keeps start before end in a block', () => {
|
||||
render(<EventCard event={dinner} variant="block" />);
|
||||
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does the same in search results', () => {
|
||||
render(
|
||||
<CalendarSearchResults
|
||||
query="dinner"
|
||||
results={{ upcoming: [dinner], past: [], searching: false }}
|
||||
calendars={calendars}
|
||||
onSelectEvent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole('button', { name: /Dinner/ });
|
||||
expect(within(row).getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
|
||||
expect(isolatedLtr(within(row).getByText('09:15 – 10:15'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does the same in the agenda', () => {
|
||||
render(
|
||||
<CalendarAgendaView
|
||||
focus={{ date: new Date(2026, 9, 28), nonce: 0 }}
|
||||
windowKey="agenda:2026-10-28"
|
||||
rangeStart={new Date(2026, 9, 28)}
|
||||
rangeEnd={new Date(2026, 10, 3)}
|
||||
events={[dinner]}
|
||||
calendars={calendars}
|
||||
onSelectEvent={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto');
|
||||
expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CalendarToolbar } from '../calendar-toolbar';
|
||||
|
||||
// The phone day view's title was cut to "Th…": the long date beside the
|
||||
// menu, the back arrow, both chevrons, Today and the header buttons.
|
||||
|
||||
vi.mock('@/components/layout/phone-header-actions', () => ({ PhoneHeaderActions: () => null }));
|
||||
|
||||
function renderToolbar(overrides: Partial<React.ComponentProps<typeof CalendarToolbar>> = {}) {
|
||||
render(
|
||||
<CalendarToolbar
|
||||
selectedDate={new Date(2026, 9, 28)}
|
||||
viewMode="day"
|
||||
onPrev={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onToday={vi.fn()}
|
||||
onViewModeChange={vi.fn()}
|
||||
onCreateEvent={vi.fn()}
|
||||
isMobile
|
||||
onMenuClick={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('phone toolbar in the day view', () => {
|
||||
it('names the day short, with the full date as its tooltip', () => {
|
||||
renderToolbar();
|
||||
const title = screen.getByText('Oct 28');
|
||||
expect(title).toHaveAttribute('title', 'Wednesday, October 28, 2026');
|
||||
});
|
||||
|
||||
it('puts the way back to the month in the menu button\'s place', () => {
|
||||
renderToolbar({ onNavigateBack: vi.fn() });
|
||||
expect(screen.getByRole('button', { name: 'back_to_month' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'nav_open_menu' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the menu when there is no way back', () => {
|
||||
renderToolbar();
|
||||
expect(screen.getByRole('button', { name: 'nav_open_menu' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CalendarWeekView } from '../calendar-week-view';
|
||||
import { CalendarDayView } from '../calendar-day-view';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// Tab in the week view walked into the events of the weeks loaded on
|
||||
// either side and scrolled the grid off its week.
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
|
||||
|
||||
function makeEvent(id: string, title: string, start: string, extra: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id, uid: id, '@type': 'Event', title, start, duration: 'PT1H', showWithoutTime: false,
|
||||
calendarIds: { 'cal-1': true }, ...extra,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
const events = [
|
||||
makeEvent('before', 'Last week', '2026-09-02T10:00:00'),
|
||||
makeEvent('now', 'This week', '2026-09-09T10:00:00'),
|
||||
makeEvent('after', 'Next week', '2026-09-16T10:00:00'),
|
||||
makeEvent('trip', 'Trip', '2026-09-03T00:00:00', { showWithoutTime: true, duration: 'P1D' }),
|
||||
makeEvent('fair', 'Fair', '2026-09-08T00:00:00', { showWithoutTime: true, duration: 'P1D' }),
|
||||
];
|
||||
|
||||
const tabbable = (name: RegExp) => screen.getByRole('button', { name }).tabIndex >= 0;
|
||||
|
||||
describe('tab order of the week view', () => {
|
||||
it('leaves the events of the weeks on either side out of the tab order', () => {
|
||||
render(
|
||||
<CalendarWeekView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="week:2026-08-31"
|
||||
// Three weeks loaded: 31 Aug to 20 Sep, the middle one in view.
|
||||
rangeStart={new Date(2026, 7, 31)}
|
||||
rangeEnd={new Date(2026, 8, 20)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={events}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
onCreateAtTime={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(tabbable(/^This week/)).toBe(true);
|
||||
expect(tabbable(/^Fair/)).toBe(true);
|
||||
expect(tabbable(/^Last week/)).toBe(false);
|
||||
expect(tabbable(/^Next week/)).toBe(false);
|
||||
expect(tabbable(/^Trip/)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the grid tab stop on a day in view when the selected day is scrolled away', () => {
|
||||
render(
|
||||
<CalendarWeekView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="week:2026-08-31"
|
||||
rangeStart={new Date(2026, 7, 31)}
|
||||
rangeEnd={new Date(2026, 8, 20)}
|
||||
selectedDate={new Date(2026, 8, 2)}
|
||||
events={[]}
|
||||
calendars={calendars}
|
||||
onSelectDate={vi.fn()}
|
||||
onSelectEvent={vi.fn()}
|
||||
onCreateAtTime={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const stops = screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0);
|
||||
expect(stops).toHaveLength(1);
|
||||
expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-07T/);
|
||||
});
|
||||
|
||||
it('does the same in the day view', () => {
|
||||
render(
|
||||
<CalendarDayView
|
||||
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
|
||||
windowKey="day:2026-09-08"
|
||||
rangeStart={new Date(2026, 8, 8)}
|
||||
rangeEnd={new Date(2026, 8, 10)}
|
||||
selectedDate={new Date(2026, 8, 9)}
|
||||
events={[makeEvent('mon', 'Monday', '2026-09-08T10:00:00'), makeEvent('tue', 'Tuesday', '2026-09-09T10:00:00')]}
|
||||
calendars={calendars}
|
||||
onSelectEvent={vi.fn()}
|
||||
onCreateAtTime={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(tabbable(/^Tuesday/)).toBe(true);
|
||||
expect(tabbable(/^Monday/)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventDetailPopover } from '../event-detail-popover';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// CAL-13: Enter on an event opened the card but the focus stayed on the
|
||||
// event: the card focused its first button while it was still hidden,
|
||||
// before it had been placed.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
useFormatter: () => ({
|
||||
dateTime: (d: Date | string) => String(d),
|
||||
relativeTime: (d: Date | string) => String(d),
|
||||
number: (n: number) => String(n),
|
||||
}),
|
||||
}));
|
||||
|
||||
const calendar = {
|
||||
id: 'cal-1',
|
||||
name: 'Work',
|
||||
myRights: { mayWriteAll: true, mayWriteOwn: true },
|
||||
} as unknown as Calendar;
|
||||
|
||||
const event = {
|
||||
id: 'evt-1',
|
||||
calendarIds: { 'cal-1': true },
|
||||
title: 'Review',
|
||||
start: '2026-11-20T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
showWithoutTime: false,
|
||||
participants: null,
|
||||
alerts: null,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
describe('EventDetailPopover focus', () => {
|
||||
it('takes the focus once it is visible, and gives it back to the event on close', () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
const visibilityAtFocus: string[] = [];
|
||||
const onFocusIn = (e: FocusEvent) => {
|
||||
const card = (e.target as HTMLElement).closest<HTMLElement>('[role="dialog"]');
|
||||
if (card) visibilityAtFocus.push(card.style.visibility);
|
||||
};
|
||||
document.addEventListener('focusin', onFocusIn);
|
||||
const { unmount } = render(
|
||||
<EventDetailPopover
|
||||
event={event}
|
||||
calendar={calendar}
|
||||
anchorRect={new DOMRect(100, 100, 50, 20)}
|
||||
pinned
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onSaveNote={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const card = screen.getByRole('dialog', { name: 'Review' });
|
||||
expect(card.contains(document.activeElement)).toBe(true);
|
||||
expect(visibilityAtFocus).toEqual(['visible']);
|
||||
unmount();
|
||||
document.removeEventListener('focusin', onFocusIn);
|
||||
expect(document.activeElement).toBe(opener);
|
||||
opener.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { RecurrenceScopeDialog } from '../recurrence-scope-dialog';
|
||||
|
||||
// The delete scope dialog asked "Which events would you like to modify?":
|
||||
// no word of what is lost (R3), and a title without a question mark.
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||
values ? `${key}(${Object.values(values).join(',')})` : key,
|
||||
}));
|
||||
|
||||
describe('RecurrenceScopeDialog', () => {
|
||||
it('asks to delete with a question and says what goes for the chosen scope', () => {
|
||||
render(
|
||||
<RecurrenceScopeDialog isOpen actionType="delete" eventTitle="Yoga" note="cancel_notification" onSelect={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: 'delete_question' });
|
||||
expect(dialog).toHaveAccessibleDescription('delete_this_message(Yoga)');
|
||||
fireEvent.click(screen.getByLabelText('all_events'));
|
||||
expect(dialog).toHaveAccessibleDescription('delete_all_message(Yoga)');
|
||||
fireEvent.click(screen.getByLabelText('this_and_future'));
|
||||
expect(dialog).toHaveAccessibleDescription('delete_following_message(Yoga)');
|
||||
// The cancellation note for an organizer's event with participants
|
||||
expect(screen.getByText('cancel_notification')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'delete' }).className).toMatch(/destructive/);
|
||||
});
|
||||
|
||||
it('keeps the edit wording for an edit', () => {
|
||||
render(<RecurrenceScopeDialog isOpen actionType="edit" eventTitle="Yoga" onSelect={vi.fn()} onClose={vi.fn()} />);
|
||||
const dialog = screen.getByRole('dialog', { name: 'edit_title' });
|
||||
expect(dialog).toHaveAccessibleDescription('description');
|
||||
expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TaskModal } from '../task-modal';
|
||||
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
|
||||
|
||||
// The task editor's Calendar select offered the read-only Birthdays
|
||||
// calendar, which the event editor leaves out.
|
||||
|
||||
const writable = { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true };
|
||||
const readOnly = { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayDelete: false };
|
||||
const calendars = [
|
||||
{ id: 'work', name: 'Work', isShared: false, myRights: writable },
|
||||
{ id: 'personal', name: 'Personal', isShared: false, myRights: writable },
|
||||
{ id: 'birthdays', name: 'Birthdays', isShared: false, myRights: readOnly },
|
||||
{ id: 'feed', name: 'Holidays feed', isShared: false, myRights: writable },
|
||||
] as unknown as Calendar[];
|
||||
const isSubscriptionCalendar = (id: string) => id === 'feed';
|
||||
|
||||
describe('task editor calendar select', () => {
|
||||
it('lists only the calendars a task can go into', () => {
|
||||
render(<TaskModal calendars={calendars} isSubscriptionCalendar={isSubscriptionCalendar} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
const select = screen.getByLabelText('tasks.calendar');
|
||||
const names = within(select).getAllByRole('option').map((o) => o.textContent);
|
||||
expect(names).toEqual(['Work', 'Personal']);
|
||||
});
|
||||
|
||||
it('keeps the calendar of a task that is already in a read-only one', () => {
|
||||
const task = {
|
||||
id: 't1', '@type': 'Task', title: 'Card for Ann', calendarIds: { birthdays: true },
|
||||
progress: 'needs-action', priority: 0, due: null, showWithoutTime: true, alerts: null,
|
||||
} as unknown as CalendarTask;
|
||||
render(<TaskModal task={task} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
const select = screen.getByLabelText('tasks.calendar') as HTMLSelectElement;
|
||||
expect(select.value).toBe('birthdays');
|
||||
});
|
||||
});
|
||||
|
||||
// The desktop task panel had no dialog role, unlike the event editor.
|
||||
describe('task editor panel', () => {
|
||||
it('is a dialog named by its title on wide screens', () => {
|
||||
render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the role to the full-screen wrapper on a phone', () => {
|
||||
render(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} isMobile />);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -263,7 +263,7 @@ export function CalendarAgendaView({
|
||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
<span className="w-28 sm:w-32 flex-shrink-0 text-sm text-muted-foreground tabular-nums">
|
||||
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
|
||||
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
|
||||
</span>
|
||||
|
||||
{/* Calendar colour as a dot on the title line; hollow when
|
||||
@@ -275,13 +275,17 @@ export function CalendarAgendaView({
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
|
||||
{ev.title || t("events.no_title")}
|
||||
{/* Sized to the title, which keeps its own direction: it sits at
|
||||
the start of the row and is cut at its own end. */}
|
||||
<div className="flex min-w-0">
|
||||
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
|
||||
{ev.title || t("events.no_title")}
|
||||
</span>
|
||||
</div>
|
||||
{(meta || getParticipantCount(ev) > 0) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
|
||||
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" />}
|
||||
{meta && <span className="truncate" title={meta}>{meta}</span>}
|
||||
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
|
||||
{getParticipantCount(ev) > 0 && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Users className="w-3 h-3" />
|
||||
|
||||
@@ -91,6 +91,7 @@ import { useCalendarEventSearch } from "@/hooks/use-calendar-event-search";
|
||||
import { CalendarSearchField, CalendarSearchResults } from "@/components/calendar/calendar-search";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useHoverPreviewGate } from "@/hooks/use-hover-preview-gate";
|
||||
import {
|
||||
computeScrollWindow, fixedScrollWindowState, freshScrollWindowState, growScrollWindow, normalizeScrollWindowState,
|
||||
scrollWindowContains, type CalendarFocus, type ScrollViewMode, type ScrollWindowOptions,
|
||||
@@ -842,16 +843,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
[closeDetail, openEmptyContextMenu],
|
||||
);
|
||||
|
||||
// No preview over an open editor, card or dialog, nor after one closed
|
||||
// until the pointer moves.
|
||||
const canHoverPreview = useHoverPreviewGate(showEventModal || showTaskModal || detailPinned || quickDraft !== null);
|
||||
|
||||
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||
if (isMobile) return;
|
||||
if (detailPinnedRef.current) return;
|
||||
if (calendarHoverPreview === 'off') return;
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
// Don't show hover popover if the sidebar is already open for this event
|
||||
if (showEventModal && editEvent?.id === event.id) return;
|
||||
if (!canHoverPreview()) return;
|
||||
if (calendarHoverPreview === 'delay-500ms' || calendarHoverPreview === 'delay-1s' || calendarHoverPreview === 'delay-2s') {
|
||||
const ms = calendarHoverPreview === 'delay-500ms' ? 500 : calendarHoverPreview === 'delay-1s' ? 1000 : 2000;
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
if (!canHoverPreview()) return;
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}, ms);
|
||||
@@ -859,7 +864,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
setDetailEvent(event);
|
||||
setDetailAnchorRect(anchorRect);
|
||||
}
|
||||
}, [isMobile, calendarHoverPreview, showEventModal, editEvent]);
|
||||
}, [isMobile, calendarHoverPreview, canHoverPreview]);
|
||||
|
||||
const handleHoverLeave = useCallback(() => {
|
||||
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
|
||||
@@ -1790,6 +1795,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
currentUserEmails={currentUserEmails}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
onContextMenuEvent={handleContextMenuEvent}
|
||||
onClearSearch={() => { setSearchQuery(""); searchInputRef.current?.focus(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2213,6 +2219,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
calendars={displayCalendars}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onDirtyChange={setTaskEditorDirty}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -197,14 +197,6 @@ export function CalendarDayView({
|
||||
return map;
|
||||
}, [days, eventsByDay, hours]);
|
||||
|
||||
const hasAllDayArea = useMemo(
|
||||
() => days.some((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
return (eventsByDay.get(key)?.allDay.length ?? 0) > 0 || (tasksByDay.get(key)?.length ?? 0) > 0;
|
||||
}),
|
||||
[days, eventsByDay, tasksByDay],
|
||||
);
|
||||
|
||||
const scrollToFocus = useCallback(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
@@ -232,6 +224,10 @@ export function CalendarDayView({
|
||||
// not used: browsers re-snap on their own when columns are prepended,
|
||||
// which would double the scroll correction.)
|
||||
const visibleKeyRef = useRef<string | null>(null);
|
||||
// The day in view: its all-day events and tasks size the row under the
|
||||
// dates, so a busy neighbour doesn't leave an empty block above the hours.
|
||||
const [visibleKey, setVisibleKey] = useState(() => dayKey(focus.date));
|
||||
useEffect(() => { setVisibleKey(dayKey(focus.date)); }, [focus.date, focus.nonce]);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const snapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleStripScroll = useCallback(() => {
|
||||
@@ -255,6 +251,7 @@ export function CalendarDayView({
|
||||
const key = dayKey(day);
|
||||
if (key === visibleKeyRef.current) return;
|
||||
visibleKeyRef.current = key;
|
||||
setVisibleKey(key);
|
||||
onVisibleDateChange?.(day);
|
||||
});
|
||||
}, [colWidth, colCount, days, onVisibleDateChange]);
|
||||
@@ -298,6 +295,10 @@ export function CalendarDayView({
|
||||
onMoveEvent,
|
||||
});
|
||||
|
||||
// Tab stays in the day in view: the loaded days on either side are out of
|
||||
// the tab order, or Tab walked into them and scrolled the strip.
|
||||
const isInView = useCallback((key: string) => key === visibleKey, [visibleKey]);
|
||||
|
||||
const { slotProps } = useTimeGridFocus({
|
||||
rootRef,
|
||||
headerRef,
|
||||
@@ -306,6 +307,7 @@ export function CalendarDayView({
|
||||
hours,
|
||||
selectedDate,
|
||||
onCreate: onCreateAtTime,
|
||||
isInView,
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
@@ -352,7 +354,16 @@ export function CalendarDayView({
|
||||
const allDayEvents = eventsByDay.get(key)?.allDay ?? [];
|
||||
const dayTasks = tasksByDay.get(key) ?? [];
|
||||
return (
|
||||
<div key={key} data-day={key} className="min-w-0 border-e border-border last:border-e-0">
|
||||
<div
|
||||
key={key}
|
||||
data-day={key}
|
||||
className={cn(
|
||||
"min-w-0 border-e border-border last:border-e-0",
|
||||
// Other days take no height of their own: they fill the
|
||||
// row the day in view sets, clipped while swiping past.
|
||||
key !== visibleKey && "h-0 min-h-full overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col items-start gap-0.5 px-3 pt-2 pb-1.5", isMobile && "px-2")}
|
||||
role="columnheader"
|
||||
@@ -376,7 +387,7 @@ export function CalendarDayView({
|
||||
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
|
||||
</div>
|
||||
|
||||
{hasAllDayArea && (
|
||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||
<div
|
||||
className="px-4 py-2 border-t border-border"
|
||||
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||
@@ -402,6 +413,7 @@ export function CalendarDayView({
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tabbable={key === visibleKey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -414,12 +426,14 @@ export function CalendarDayView({
|
||||
<div className="space-y-0.5">
|
||||
{/* The same chip as the month and week grids. */}
|
||||
{dayTasks.map((task) => (
|
||||
<div key={task.id} className="h-5">
|
||||
<div key={task.id} className={isMobile ? undefined : "h-5"}>
|
||||
<CalendarTaskChip
|
||||
task={task}
|
||||
calendar={calendars.find((c) => task.calendarIds[c.id])}
|
||||
onToggleComplete={onToggleTaskComplete}
|
||||
onSelect={onSelectTask}
|
||||
touch={isMobile}
|
||||
tabbable={key === visibleKey}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -487,7 +501,7 @@ export function CalendarDayView({
|
||||
onClick={() => handleSlotClick(day, h)}
|
||||
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
||||
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
|
||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary"
|
||||
style={{ height: hourHeight }}
|
||||
/>
|
||||
))}
|
||||
@@ -497,6 +511,7 @@ export function CalendarDayView({
|
||||
count={dayLayout.before}
|
||||
direction="before"
|
||||
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
|
||||
tabbable={key === visibleKey}
|
||||
/>
|
||||
)}
|
||||
{dayLayout && dayLayout.after > 0 && (
|
||||
@@ -504,6 +519,7 @@ export function CalendarDayView({
|
||||
count={dayLayout.after}
|
||||
direction="after"
|
||||
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
|
||||
tabbable={key === visibleKey}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -541,6 +557,7 @@ export function CalendarDayView({
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tabbable={key === visibleKey}
|
||||
draggable={editable}
|
||||
continuesBefore={clip.clippedStart}
|
||||
continuesAfter={clip.clippedEnd}
|
||||
@@ -603,7 +620,7 @@ export function CalendarDayView({
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
|
||||
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
|
||||
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -649,7 +666,7 @@ export function CalendarDayView({
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
<div className="text-[10.5px] opacity-85 truncate">
|
||||
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { format } from "date-fns";
|
||||
import { Search, X, MapPin, Repeat } from "@/components/icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -88,11 +89,13 @@ interface CalendarSearchResultsProps {
|
||||
currentUserEmails?: string[];
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
||||
/** Empties the search box, back to the calendar (R9). */
|
||||
onClearSearch?: () => void;
|
||||
}
|
||||
|
||||
/** The events a calendar search found, upcoming first, then past ones. */
|
||||
export function CalendarSearchResults({
|
||||
query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent,
|
||||
query, results, calendars, timeFormat = "24h", currentUserEmails, onSelectEvent, onContextMenuEvent, onClearSearch,
|
||||
}: CalendarSearchResultsProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useDisplayDateFormatter();
|
||||
@@ -148,7 +151,7 @@ export function CalendarSearchResults({
|
||||
: { month: "short", day: "numeric", year: "numeric" })}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`}
|
||||
{ev.showWithoutTime ? t("events.all_day") : <bdi dir="ltr">{`${formatTime(start)} – ${formatTime(end)}`}</bdi>}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
@@ -157,13 +160,15 @@ export function CalendarSearchResults({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={cn("block text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
|
||||
{title}
|
||||
<span className="flex min-w-0">
|
||||
<span dir="auto" className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
{(meta || repeats) && (
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
|
||||
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" aria-hidden="true" />}
|
||||
{meta && <span className="truncate" title={meta}>{meta}</span>}
|
||||
{meta && <span dir="auto" className="truncate" title={meta}>{meta}</span>}
|
||||
{repeats && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
<Repeat className="w-3 h-3" aria-hidden="true" />
|
||||
@@ -199,6 +204,11 @@ export function CalendarSearchResults({
|
||||
icon={Search}
|
||||
title={t("search.no_results", { query: query.trim() })}
|
||||
description={t("search.no_results_hint")}
|
||||
actions={onClearSearch && (
|
||||
<Button size="sm" variant="outline" onClick={onClearSearch}>
|
||||
{t("search.clear")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{section("calendar-search-upcoming", t("search.upcoming"), upcoming)}
|
||||
|
||||
@@ -106,6 +106,7 @@ export function CalendarToolbar({
|
||||
formatWeekRange,
|
||||
formatWeekRangeShort,
|
||||
formatFullDate,
|
||||
formatDayShort,
|
||||
} = useCalendarLocale();
|
||||
const views: CalendarViewMode[] = enableCalendarTasks
|
||||
? ["month", "week", "day", "agenda", "tasks"]
|
||||
@@ -152,8 +153,9 @@ export function CalendarToolbar({
|
||||
: formatWeekRange(ws);
|
||||
}
|
||||
case "day":
|
||||
// A phone's row has room for "Oct 28"; the full date is the tooltip.
|
||||
return isMobile
|
||||
? formatFullDate(titleDate)
|
||||
? formatDayShort(titleDate)
|
||||
: formatFullDate(titleDate);
|
||||
case "agenda":
|
||||
return isMobile
|
||||
@@ -227,7 +229,8 @@ export function CalendarToolbar({
|
||||
) : (
|
||||
/* Row 1: Back / Date nav / Today */
|
||||
<div className="flex items-center gap-1">
|
||||
{onMenuClick && (
|
||||
{/* The way back to the month takes the menu's place: both left the date no room. */}
|
||||
{onMenuClick && !onNavigateBack && (
|
||||
<IconButton size="icon-lg" label={t("nav_open_menu")} onClick={onMenuClick} className="-ms-1 touch-manipulation">
|
||||
<Menu />
|
||||
</IconButton>
|
||||
@@ -242,7 +245,10 @@ export function CalendarToolbar({
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-center flex-1 select-none truncate" title={getDateLabel()}>
|
||||
<span
|
||||
className="text-sm font-semibold text-center flex-1 select-none truncate"
|
||||
title={viewMode === "day" ? formatFullDate(titleDate) : getDateLabel()}
|
||||
>
|
||||
{getDateLabel()}
|
||||
</span>
|
||||
{hasDateNav && (
|
||||
@@ -261,6 +267,8 @@ export function CalendarToolbar({
|
||||
)}
|
||||
|
||||
{/* Row 2: View switcher pills + calendar toggle */}
|
||||
{/* Each pill is as wide as its label plus an even share of the rest, so a
|
||||
long label ("جدول الأعمال", "Aufgaben") neither wraps nor touches the edge. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-1 border border-border rounded-md overflow-hidden">
|
||||
{views.map((v) => (
|
||||
@@ -268,7 +276,7 @@ export function CalendarToolbar({
|
||||
key={v}
|
||||
onClick={() => onViewModeChange(v)}
|
||||
className={cn(
|
||||
"flex-1 min-h-11 text-[11px] font-medium transition-colors touch-manipulation",
|
||||
"flex-auto min-h-11 px-1.5 text-[11px] font-medium transition-colors touch-manipulation",
|
||||
v === viewMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground active:bg-muted"
|
||||
|
||||
@@ -394,6 +394,17 @@ export function CalendarWeekView({
|
||||
onMoveEvent,
|
||||
});
|
||||
|
||||
// The days in view. Tab stays in them: events of the weeks loaded on
|
||||
// either side are out of the tab order, or Tab walked into them and
|
||||
// scrolled the grid off its week.
|
||||
const inViewKeys = useMemo(
|
||||
() => new Set(days.slice(visibleStartIndex, visibleStartIndex + visibleColumns).map(dayKey)),
|
||||
[days, visibleStartIndex, visibleColumns],
|
||||
);
|
||||
const isInView = useCallback((key: string) => inViewKeys.has(key), [inViewKeys]);
|
||||
const spanInView = (startIndex: number, span: number) =>
|
||||
startIndex < visibleStartIndex + visibleColumns && startIndex + span > visibleStartIndex;
|
||||
|
||||
const { slotProps, tabStopDayKey } = useTimeGridFocus({
|
||||
rootRef,
|
||||
headerRef,
|
||||
@@ -402,6 +413,7 @@ export function CalendarWeekView({
|
||||
hours,
|
||||
selectedDate,
|
||||
onCreate: onCreateAtTime,
|
||||
isInView,
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
@@ -553,6 +565,7 @@ export function CalendarWeekView({
|
||||
onMouseLeave={onHoverLeave}
|
||||
onContextMenu={onContextMenuEvent}
|
||||
currentUserEmails={currentUserEmails}
|
||||
tabbable={spanInView(segment.startIndex, segment.span)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -606,6 +619,7 @@ export function CalendarWeekView({
|
||||
calendar={calendars.find(c => task.calendarIds[c.id])}
|
||||
onToggleComplete={onToggleTaskComplete}
|
||||
onSelect={onSelectTask}
|
||||
tabbable={inViewKeys.has(key)}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
@@ -671,7 +685,7 @@ export function CalendarWeekView({
|
||||
onClick={() => handleSlotClick(day, h)}
|
||||
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
||||
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
|
||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
className="relative border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[15] focus-visible:after:pointer-events-none focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-primary"
|
||||
style={{ height: hourHeight }}
|
||||
/>
|
||||
))}
|
||||
@@ -681,6 +695,7 @@ export function CalendarWeekView({
|
||||
count={dayLayout.before}
|
||||
direction="before"
|
||||
onReveal={() => revealMinutes(dayLayout.firstBeforeMinutes ?? 0)}
|
||||
tabbable={inViewKeys.has(key)}
|
||||
/>
|
||||
)}
|
||||
{dayLayout && dayLayout.after > 0 && (
|
||||
@@ -688,6 +703,7 @@ export function CalendarWeekView({
|
||||
count={dayLayout.after}
|
||||
direction="after"
|
||||
onReveal={() => revealMinutes(dayLayout.firstAfterMinutes ?? hours.endMinutes)}
|
||||
tabbable={inViewKeys.has(key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -728,6 +744,7 @@ export function CalendarWeekView({
|
||||
draggable={editable}
|
||||
continuesBefore={clip.clippedStart}
|
||||
continuesAfter={clip.clippedEnd}
|
||||
tabbable={inViewKeys.has(key)}
|
||||
/>
|
||||
{/* An edge beyond the visible hours has nothing to grab. */}
|
||||
{editable && !clip.clippedStart && (
|
||||
@@ -787,7 +804,7 @@ export function CalendarWeekView({
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
|
||||
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
|
||||
<bdi dir="ltr">{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -834,7 +851,7 @@ export function CalendarWeekView({
|
||||
{pendingPreview.title}
|
||||
</div>
|
||||
<div className="text-[10.5px] opacity-85 truncate">
|
||||
{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
|
||||
<bdi dir="ltr">{formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,13 +9,15 @@ interface HiddenEventsIndicatorProps {
|
||||
count: number;
|
||||
direction: "before" | "after";
|
||||
onReveal: () => void;
|
||||
/** False for a day scrolled out of view: Tab skips it. */
|
||||
tabbable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a day column that has events above or below the visible hours
|
||||
* (#1164); clicking it shows the whole day at the first of them.
|
||||
*/
|
||||
export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEventsIndicatorProps) {
|
||||
export function HiddenEventsIndicator({ count, direction, onReveal, tabbable = true }: HiddenEventsIndicatorProps) {
|
||||
const t = useTranslations("calendar.events");
|
||||
const label = direction === "before" ? t("hidden_before", { count }) : t("hidden_after", { count });
|
||||
const Icon = direction === "before" ? ChevronUp : ChevronDown;
|
||||
@@ -23,6 +25,7 @@ export function HiddenEventsIndicator({ count, direction, onReveal }: HiddenEven
|
||||
<button
|
||||
type="button"
|
||||
data-hidden-events={direction}
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
// The column starts drag-to-create on pointer down.
|
||||
|
||||
@@ -33,6 +33,8 @@ interface EventCardProps {
|
||||
style?: CSSProperties;
|
||||
/** The user's calendar addresses, to tell whether they declined the event. */
|
||||
currentUserEmails?: string[];
|
||||
/** False for a day scrolled out of view: Tab skips it (arrows still reach it). */
|
||||
tabbable?: boolean;
|
||||
}
|
||||
|
||||
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
|
||||
@@ -98,7 +100,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string)
|
||||
return el;
|
||||
}
|
||||
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails }: EventCardProps) {
|
||||
export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true }: EventCardProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const [isBeingDragged, setIsBeingDragged] = useState(false);
|
||||
const color = getEventColor(event, calendar);
|
||||
@@ -184,6 +186,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
onContextMenu={handleContextMenu}
|
||||
{...touchProps}
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"flex items-center w-full text-start text-xs font-medium px-1.5 py-0.5 rounded-xs truncate",
|
||||
@@ -194,7 +197,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
)}
|
||||
style={{ ...eventFillStyle(color, { inactive: isInactive, selected: isSelected }), ...style }}
|
||||
>
|
||||
<span className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
<span dir="auto" className={cn("truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -208,6 +211,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
onContextMenu={handleContextMenu}
|
||||
{...touchProps}
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
@@ -223,7 +227,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
{showTimeInMonthView && !event.showWithoutTime && (
|
||||
<span className="flex-shrink-0 font-normal opacity-85">{format(startDate, timeFmt)}</span>
|
||||
)}
|
||||
<span className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
<span dir="auto" className={cn("truncate font-medium", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -237,6 +241,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
onContextMenu={handleContextMenu}
|
||||
{...touchProps}
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
{...dragProps}
|
||||
data-calendar-event
|
||||
data-continues-before={continuesBefore || undefined}
|
||||
@@ -263,7 +268,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
block is wide enough to keep the title readable. */}
|
||||
<div className="@container">
|
||||
<div className="flex items-baseline gap-1 min-w-0">
|
||||
<span className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
<span dir="auto" className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
|
||||
{!event.showWithoutTime && durationMinutes < 45 && (
|
||||
<span className="hidden @min-[120px]:inline flex-shrink-0 opacity-85 text-[10.5px]">{safeFormat(startDate, timeFmt)}</span>
|
||||
)}
|
||||
@@ -271,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
</div>
|
||||
{!event.showWithoutTime && durationMinutes >= 45 && (
|
||||
<div className="opacity-85 text-[10.5px]">
|
||||
{timeString}
|
||||
{/* Start before end in a right-to-left layout too. */}
|
||||
<bdi dir="ltr">{timeString}</bdi>
|
||||
</div>
|
||||
)}
|
||||
{getParticipantCount(event) > 0 && (
|
||||
|
||||
@@ -253,17 +253,40 @@ export function EventDetailPopover({
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRect, isMobile]);
|
||||
|
||||
// A card opened by click or Enter takes the focus, and gives it back to
|
||||
// the event when it closes.
|
||||
useEffect(() => {
|
||||
if (!pinned) return;
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const node = popoverRef.current;
|
||||
node?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
const active = document.activeElement;
|
||||
if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true });
|
||||
};
|
||||
}, [pinned]);
|
||||
|
||||
// The focus goes in once the card is placed: before, it is hidden and
|
||||
// can't take the focus, which stayed on the event. A card that just
|
||||
// turned visible can still count as hidden for a frame, so it tries again
|
||||
// on the next ones, unless the focus moved somewhere else meanwhile.
|
||||
const placed = position !== null;
|
||||
useEffect(() => {
|
||||
const node = popoverRef.current;
|
||||
if (!pinned || !placed || !node) return;
|
||||
const from = document.activeElement;
|
||||
let frame = 0;
|
||||
let tries = 0;
|
||||
const attempt = () => {
|
||||
const active = document.activeElement;
|
||||
if (node.contains(active) || active !== from) return;
|
||||
const first = node.querySelector<HTMLElement>("button");
|
||||
first?.focus({ preventScroll: true });
|
||||
if (first && document.activeElement !== first && ++tries < 30) frame = requestAnimationFrame(attempt);
|
||||
};
|
||||
attempt();
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [pinned, placed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -400,7 +423,8 @@ export function EventDetailPopover({
|
||||
"text-lg font-semibold leading-snug break-words text-foreground",
|
||||
event.status === "cancelled" && "line-through text-muted-foreground"
|
||||
)}>
|
||||
{event.title || t("events.no_title")}
|
||||
{/* Its own direction, next to the colour square. */}
|
||||
<bdi>{event.title || t("events.no_title")}</bdi>
|
||||
</h3>
|
||||
{event.status === "tentative" && (
|
||||
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-amber-600 dark:text-amber-400">
|
||||
@@ -463,7 +487,7 @@ export function EventDetailPopover({
|
||||
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
|
||||
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -481,6 +505,7 @@ export function EventDetailPopover({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary hover:underline min-w-0 break-words"
|
||||
dir="auto"
|
||||
title={locationName}
|
||||
>
|
||||
{/^https?:\/\//i.test(locationName)
|
||||
@@ -534,7 +559,7 @@ export function EventDetailPopover({
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{participants.slice(0, 5).map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate text-foreground" title={p.email}>
|
||||
<span dir="auto" className="truncate text-foreground" title={p.email}>
|
||||
{p.name || p.email}
|
||||
{p.isOrganizer && (
|
||||
<span className="text-muted-foreground ms-1">
|
||||
@@ -575,7 +600,7 @@ export function EventDetailPopover({
|
||||
{calendar && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
|
||||
<span dir="auto" className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -583,7 +608,7 @@ export function EventDetailPopover({
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
|
||||
<p dir="auto" className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3 break-words">
|
||||
<LinkifiedText text={event.description} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Upload, Check, RefreshCw, Globe } from "@/components/icons";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { format } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
@@ -53,6 +52,9 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
const [urlInput, setUrlInput] = useState(initialUrl || "");
|
||||
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// The dialog opens on the drop zone, or on the address field for a link.
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null);
|
||||
const urlInputRef = useRef<HTMLInputElement>(null);
|
||||
const targetId = useId();
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
@@ -209,10 +211,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
if (!event.start) return "";
|
||||
try {
|
||||
const date = getEventStartDate(event as CalendarEvent);
|
||||
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
|
||||
return event.showWithoutTime
|
||||
? format(date, "MMM d, yyyy")
|
||||
: format(date, `MMM d, yyyy ${timeFmt}`);
|
||||
// The locale's own order and month names, not an English pattern.
|
||||
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
...(event.showWithoutTime ? {} : timeFormat === "12h"
|
||||
? { hour: "numeric", minute: "2-digit", hour12: true }
|
||||
: { hour: "2-digit", minute: "2-digit", hour12: false }),
|
||||
});
|
||||
} catch {
|
||||
return event.start;
|
||||
}
|
||||
@@ -229,6 +236,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
onClose={close}
|
||||
title={t("title")}
|
||||
size="full-on-phone"
|
||||
initialFocus={importMode === "url" ? urlInputRef : dropZoneRef}
|
||||
dirty={step !== "select" || urlInput.trim() !== (initialUrl ?? "")}
|
||||
footer={step !== "importing" ? (
|
||||
<>
|
||||
@@ -281,6 +289,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
{importMode === "file" && (
|
||||
<>
|
||||
<button
|
||||
ref={dropZoneRef}
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -313,6 +322,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
<p className="text-sm text-muted-foreground">{t("url_description")}</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={urlInputRef}
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
|
||||
@@ -15,14 +15,23 @@ interface RecurrenceScopeDialogProps {
|
||||
onClose: () => void;
|
||||
/** A line under the question, e.g. that the participants get a cancellation. */
|
||||
note?: string;
|
||||
/** The series' title: a delete says which of its events go (R3). */
|
||||
eventTitle?: string;
|
||||
}
|
||||
|
||||
const DELETE_MESSAGES: Record<RecurrenceEditScope, string> = {
|
||||
this: "delete_this_message",
|
||||
this_and_future: "delete_following_message",
|
||||
all: "delete_all_message",
|
||||
};
|
||||
|
||||
export function RecurrenceScopeDialog({
|
||||
isOpen,
|
||||
actionType,
|
||||
onSelect,
|
||||
onClose,
|
||||
note,
|
||||
eventTitle = "",
|
||||
}: RecurrenceScopeDialogProps) {
|
||||
const t = useTranslations("calendar.recurrence_scope");
|
||||
const id = useId();
|
||||
@@ -72,10 +81,13 @@ export function RecurrenceScopeDialog({
|
||||
</div>
|
||||
<div>
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold">
|
||||
{isDelete ? t("delete_title") : isRsvp ? t("rsvp_title") : t("edit_title")}
|
||||
{isDelete ? t("delete_question") : isRsvp ? t("rsvp_title") : t("edit_title")}
|
||||
</h2>
|
||||
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1">
|
||||
{isRsvp ? t("rsvp_description") : t("description")}
|
||||
{/* A delete says what goes with the chosen scope, and that it is for good. */}
|
||||
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1" aria-live="polite">
|
||||
{isDelete
|
||||
? t(DELETE_MESSAGES[choice], { title: eventTitle })
|
||||
: isRsvp ? t("rsvp_description") : t("description")}
|
||||
</p>
|
||||
{note && <p className="text-sm text-muted-foreground mt-1">{note}</p>}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,10 @@ interface CalendarTaskChipProps {
|
||||
calendar?: Calendar;
|
||||
onToggleComplete?: (task: CalendarTask) => void;
|
||||
onSelect?: (task: CalendarTask) => void;
|
||||
/** A full row for a finger: 44px tall, a bigger circle with a 44px hit area (phone day view). */
|
||||
touch?: boolean;
|
||||
/** False for a day scrolled out of view: Tab skips it. */
|
||||
tabbable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -17,7 +21,7 @@ interface CalendarTaskChipProps {
|
||||
* A task on its due day in the month and week grids (#1107). The circle
|
||||
* toggles completion; the title opens the task in the editor.
|
||||
*/
|
||||
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, className }: CalendarTaskChipProps) {
|
||||
export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, touch = false, tabbable = true, className }: CalendarTaskChipProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isCompleted = task.progress === "completed";
|
||||
const color = calendar?.color || "#3b82f6";
|
||||
@@ -26,33 +30,45 @@ export function CalendarTaskChip({ task, calendar, onToggleComplete, onSelect, c
|
||||
return (
|
||||
<div
|
||||
data-calendar-task={task.id}
|
||||
className={cn("h-full rounded text-[10px] font-medium flex items-center overflow-hidden", className)}
|
||||
className={cn(
|
||||
"rounded font-medium flex items-center overflow-hidden",
|
||||
touch ? "min-h-11 text-xs" : "h-full text-[10px]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: `${color}20`, borderInlineStart: `3px solid ${color}` }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleComplete?.(task); }}
|
||||
className="group h-full ps-1 pe-0.5 flex items-center flex-shrink-0"
|
||||
className={cn(
|
||||
"group flex items-center flex-shrink-0",
|
||||
touch ? "self-stretch w-11 justify-center" : "h-full ps-1 pe-0.5",
|
||||
)}
|
||||
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-2.5 h-2.5 rounded-full border flex items-center justify-center",
|
||||
"rounded-full border flex items-center justify-center",
|
||||
touch ? "w-4 h-4" : "w-2.5 h-2.5",
|
||||
isCompleted ? "bg-success border-success text-success-foreground" : "group-hover:bg-background",
|
||||
)}
|
||||
style={isCompleted ? undefined : { borderColor: color }}
|
||||
>
|
||||
{isCompleted && <Check className="h-2 w-2" />}
|
||||
{isCompleted && <Check className={touch ? "h-3 w-3" : "h-2 w-2"} />}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={tabbable ? undefined : -1}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect?.(task); }}
|
||||
className={cn(
|
||||
"h-full flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
|
||||
"flex-1 min-w-0 truncate text-start pe-1 hover:opacity-80",
|
||||
touch ? "self-stretch" : "h-full",
|
||||
isCompleted && "line-through text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
dir="auto"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { taskToView } from "@/lib/plugin-projection";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { canCreateEventsIn } from "@/lib/calendar-editability";
|
||||
|
||||
interface TaskModalProps {
|
||||
task?: CalendarTask | null;
|
||||
@@ -25,6 +26,8 @@ interface TaskModalProps {
|
||||
/** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
isMobile?: boolean;
|
||||
/** Subscribed feeds are read-only: no tasks go there. */
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}
|
||||
|
||||
type PriorityLevel = "none" | "high" | "medium" | "low";
|
||||
@@ -53,7 +56,8 @@ export function TaskModal({
|
||||
onDelete,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
isMobile: _isMobile,
|
||||
isMobile = false,
|
||||
isSubscriptionCalendar,
|
||||
}: TaskModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -63,7 +67,9 @@ export function TaskModal({
|
||||
// task-detail-section: the saved task, for an existing task only.
|
||||
const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]);
|
||||
|
||||
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
|
||||
// The calendars a task can go into: not read-only ones (Birthdays,
|
||||
// feeds), the same rule as the event editor.
|
||||
const writableCalendars = calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar));
|
||||
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||
|
||||
const [title, setTitle] = useState(task?.title ?? "");
|
||||
@@ -80,6 +86,8 @@ export function TaskModal({
|
||||
}
|
||||
return defaultCalendarId;
|
||||
});
|
||||
// A task already in a read-only calendar keeps it listed.
|
||||
const calendarOptions = calendars.filter((c) => c.id === calendarId || writableCalendars.includes(c));
|
||||
const [alertOption, setAlertOption] = useState<AlertOption>(() => {
|
||||
if (!task?.alerts) return "none";
|
||||
const first = Object.values(task.alerts)[0];
|
||||
@@ -205,10 +213,17 @@ export function TaskModal({
|
||||
}, [requestClose, handleSave]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
|
||||
// The desktop side panel is a dialog named by its title, like the event
|
||||
// editor; on a phone the full-screen wrapper around it is.
|
||||
<div
|
||||
role={isMobile ? undefined : "dialog"}
|
||||
aria-labelledby={isMobile ? undefined : `${id}-heading`}
|
||||
className="flex flex-col h-full bg-background"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold">
|
||||
<h2 id={`${id}-heading`} className="text-sm font-semibold">
|
||||
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||
</h2>
|
||||
<IconButton label={tCommon("close")} onClick={requestClose}>
|
||||
@@ -313,7 +328,7 @@ export function TaskModal({
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
{writableCalendars.length > 1 && (
|
||||
{calendarOptions.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={`${id}-calendar`} className="text-xs font-medium text-muted-foreground">
|
||||
{t("tasks.calendar")}
|
||||
@@ -324,7 +339,7 @@ export function TaskModal({
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
{writableCalendars.map((cal) => (
|
||||
{calendarOptions.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,11 @@ export interface CalendarSearchResults {
|
||||
}
|
||||
|
||||
// One row per event: a series found as loaded occurrences and as the
|
||||
// server's base event shows once.
|
||||
const seriesKey = (event: CalendarEvent) => `${event.accountId ?? ""} ${event.uid || event.originalId || event.id}`;
|
||||
// server's base event shows once. An occurrence renamed on its own gets a
|
||||
// row of its own, so the series still shows under its own title: the next
|
||||
// occurrence being the renamed one hid the series.
|
||||
const seriesKey = (event: CalendarEvent) =>
|
||||
`${event.accountId ?? ""} ${event.uid || event.originalId || event.id} ${event.title ?? ""}`;
|
||||
|
||||
/**
|
||||
* Searches the user's events by title, place, description and participants
|
||||
|
||||
@@ -200,6 +200,21 @@ export function useCalendarLocale() {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Short day label for a phone's title ("Oct 28", "28. Okt."). The weekday
|
||||
* is in the day's header right under it, and with it German no longer fit.
|
||||
*/
|
||||
const formatDayShort = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jm, jd } = toJalali(date);
|
||||
return `${jd} ${JALALI_MONTHS[jm - 1]}`;
|
||||
}
|
||||
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Calendar-system-aware month/year getters (for navigation, etc.)
|
||||
// All return values use **0-based** months to stay compatible with
|
||||
@@ -244,6 +259,7 @@ export function useCalendarLocale() {
|
||||
formatWeekRange,
|
||||
formatWeekRangeShort,
|
||||
formatFullDate,
|
||||
formatDayShort,
|
||||
getMonth,
|
||||
getYear,
|
||||
monthLabelKeys,
|
||||
|
||||
@@ -4,13 +4,10 @@ import { format } from "date-fns";
|
||||
import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils";
|
||||
|
||||
/**
|
||||
* Returns a memoized function that formats a calendar event date
|
||||
* using the current locale for day and month names.
|
||||
*
|
||||
* The string will be in the format: "EEE, MMM d, yyyy"
|
||||
*
|
||||
* For example: "Wed, Apr 29, 2026" (en)
|
||||
* "Qua, Abr 29, 2026" (pt)
|
||||
* Returns a memoized function that formats a calendar event date in the
|
||||
* current locale's own order and names (Intl), e.g.
|
||||
* "Wed, Apr 29, 2026" (en)
|
||||
* "Mi., 29. Apr. 2026" (de)
|
||||
*
|
||||
* When the Jalali calendar is active (fa locale), the format uses
|
||||
* Persian day/month names with the Jalali year, e.g.:
|
||||
@@ -31,12 +28,14 @@ export function useFormatEventDate(): (date: Date) => string {
|
||||
return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`;
|
||||
}
|
||||
|
||||
const dayOfWeek = format(date, "EEE").toLowerCase();
|
||||
const month = format(date, "MMM").toLowerCase();
|
||||
const day = format(date, "d");
|
||||
const year = format(date, "yyyy");
|
||||
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
|
||||
// A display date: its local fields are the wall-clock to show.
|
||||
return date.toLocaleDateString(locale === "en" ? "en-US" : locale, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
},
|
||||
[t, isJalali]
|
||||
[t, isJalali, locale]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { isContextMenuOpen } from "@/components/ui/context-menu";
|
||||
|
||||
/** How far the pointer has to move before a preview may open again. */
|
||||
const MOVE_THRESHOLD = 4;
|
||||
|
||||
const MODAL_SELECTOR = '[aria-modal="true"]';
|
||||
|
||||
/**
|
||||
* Decides whether a hover preview may open. Not while an editor, a card,
|
||||
* a menu or a dialog is open (`overlayOpen` plus any modal on the page),
|
||||
* and not after one closed until the pointer really moves: the pointer
|
||||
* resting where the dialog was otherwise "hovers" the event under it and
|
||||
* the preview opens by itself.
|
||||
*/
|
||||
export function useHoverPreviewGate(overlayOpen: boolean): () => boolean {
|
||||
const overlayOpenRef = useRef(overlayOpen);
|
||||
const pointer = useRef<{ x: number; y: number } | null>(null);
|
||||
// Where the pointer was when previews were held back; null when free.
|
||||
const heldAt = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const hold = useCallback(() => {
|
||||
heldAt.current = pointer.current ?? { x: Number.NaN, y: Number.NaN };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent | MouseEvent) => {
|
||||
const at = { x: e.clientX, y: e.clientY };
|
||||
pointer.current = at;
|
||||
const held = heldAt.current;
|
||||
if (!held) return;
|
||||
// Position unknown when it was held: the first move frees it.
|
||||
if (Number.isNaN(held.x) || Math.hypot(at.x - held.x, at.y - held.y) > MOVE_THRESHOLD) {
|
||||
heldAt.current = null;
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", onMove, { passive: true });
|
||||
return () => window.removeEventListener("pointermove", onMove);
|
||||
}, []);
|
||||
|
||||
// An editor, card or quick editor of the calendar closing.
|
||||
useEffect(() => {
|
||||
const was = overlayOpenRef.current;
|
||||
overlayOpenRef.current = overlayOpen;
|
||||
if (was && !overlayOpen) hold();
|
||||
}, [overlayOpen, hold]);
|
||||
|
||||
// Any other dialog (a confirm, import, shortcuts) portalled onto <body>.
|
||||
useEffect(() => {
|
||||
if (typeof MutationObserver === "undefined") return;
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
for (const node of record.removedNodes) {
|
||||
if (node instanceof Element && (node.matches(MODAL_SELECTOR) || node.querySelector(MODAL_SELECTOR))) {
|
||||
hold();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
return () => observer.disconnect();
|
||||
}, [hold]);
|
||||
|
||||
return useCallback(() => {
|
||||
if (overlayOpenRef.current || isContextMenuOpen() || document.querySelector(MODAL_SELECTOR)) {
|
||||
hold();
|
||||
return false;
|
||||
}
|
||||
return heldAt.current === null;
|
||||
}, [hold]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "هذا الحدث والأحداث التالية",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "این رویداد و رویدادهای بعدی",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "זה והאירועים הבאים",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "これ以降のすべてのイベント",
|
||||
|
||||
@@ -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": "이 일정 및 향후 일정 모두",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Энэ болон дараах үйл явдлууд",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Это и последующие события",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "กิจกรรมนี้และกิจกรรมที่ตามมา",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ця та наступні події",
|
||||
|
||||
@@ -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": "本次及後續活動",
|
||||
|
||||
@@ -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": "本次及后续活动",
|
||||
|
||||
Reference in New Issue
Block a user