diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 843e003d9..4b36fb2f2 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -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(<>); 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(); }); diff --git a/components/calendar/__tests__/calendar-day-view-phone.test.tsx b/components/calendar/__tests__/calendar-day-view-phone.test.tsx new file mode 100644 index 000000000..c7365c4c4 --- /dev/null +++ b/components/calendar/__tests__/calendar-day-view-phone.test.tsx @@ -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( + , + ); +} + +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('[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('[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('[data-day="2026-09-09"]')!; + const next = document.querySelector('[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); + }); +}); diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx index bc6b39924..4b408c769 100644 --- a/components/calendar/__tests__/calendar-dialogs.test.tsx +++ b/components/calendar/__tests__/calendar-dialogs.test.tsx @@ -61,6 +61,16 @@ describe('import dialog from the keyboard', () => { render(); 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(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: /select_file/ })); + unmount(); + render(); + expect(document.activeElement).toBe(screen.getByLabelText('tab_url')); + }); }); describe('import preview', () => { diff --git a/components/calendar/__tests__/calendar-rtl-text.test.tsx b/components/calendar/__tests__/calendar-rtl-text.test.tsx new file mode 100644 index 000000000..9b728c1f6 --- /dev/null +++ b/components/calendar/__tests__/calendar-rtl-text.test.tsx @@ -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()), + 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(); + expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto'); + }); + + it('keeps start before end in a block', () => { + render(); + expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true); + }); + + it('does the same in search results', () => { + render( + , + ); + 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( + , + ); + expect(screen.getByText('Dinner with Carlos and co.')).toHaveAttribute('dir', 'auto'); + expect(isolatedLtr(screen.getByText('09:15 – 10:15'))).toBe(true); + }); +}); diff --git a/components/calendar/__tests__/calendar-toolbar-phone.test.tsx b/components/calendar/__tests__/calendar-toolbar-phone.test.tsx new file mode 100644 index 000000000..dc9171553 --- /dev/null +++ b/components/calendar/__tests__/calendar-toolbar-phone.test.tsx @@ -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> = {}) { + render( + , + ); +} + +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(); + }); +}); diff --git a/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx new file mode 100644 index 000000000..a188afdf6 --- /dev/null +++ b/components/calendar/__tests__/calendar-week-view-tab-order.test.tsx @@ -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 { + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(tabbable(/^Tuesday/)).toBe(true); + expect(tabbable(/^Monday/)).toBe(false); + }); +}); diff --git a/components/calendar/__tests__/event-detail-popover-focus.test.tsx b/components/calendar/__tests__/event-detail-popover-focus.test.tsx new file mode 100644 index 000000000..787cc35c8 --- /dev/null +++ b/components/calendar/__tests__/event-detail-popover-focus.test.tsx @@ -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('[role="dialog"]'); + if (card) visibilityAtFocus.push(card.style.visibility); + }; + document.addEventListener('focusin', onFocusIn); + const { unmount } = render( + , + ); + 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(); + }); +}); diff --git a/components/calendar/__tests__/recurrence-scope-dialog.test.tsx b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx new file mode 100644 index 000000000..7f93c7c81 --- /dev/null +++ b/components/calendar/__tests__/recurrence-scope-dialog.test.tsx @@ -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) => + 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( + , + ); + 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(); + const dialog = screen.getByRole('dialog', { name: 'edit_title' }); + expect(dialog).toHaveAccessibleDescription('description'); + expect(screen.getByRole('button', { name: 'save' })).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/task-modal-calendars.test.tsx b/components/calendar/__tests__/task-modal-calendars.test.tsx new file mode 100644 index 000000000..15eb01375 --- /dev/null +++ b/components/calendar/__tests__/task-modal-calendars.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument(); + }); + + it('leaves the role to the full-screen wrapper on a phone', () => { + render(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 850321a93..4dd69715a 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -263,7 +263,7 @@ export function CalendarAgendaView({ style={{ paddingBlock: 'var(--density-item-py)' }} > - {ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`} + {ev.showWithoutTime ? t("events.all_day") : {`${formatTime(start)} – ${formatTime(end)}`}} {/* Calendar colour as a dot on the title line; hollow when @@ -275,13 +275,17 @@ export function CalendarAgendaView({ />
-
- {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. */} +
+ + {ev.title || t("events.no_title")} +
{(meta || getParticipantCount(ev) > 0) && (
{locationName && } - {meta && {meta}} + {meta && {meta}} {getParticipantCount(ev) > 0 && ( diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index ce2b3e79e..6b29b5ea0 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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)} /> diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index d927ee602..db225598a 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -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(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(null); const snapTimerRef = useRef | 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 ( -
+
- {hasAllDayArea && ( + {(allDayEvents.length > 0 || dayTasks.length > 0) && (
{ @@ -402,6 +413,7 @@ export function CalendarDayView({ onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} + tabbable={key === visibleKey} /> ); })} @@ -414,12 +426,14 @@ export function CalendarDayView({
{/* The same chip as the month and week grids. */} {dayTasks.map((task) => ( -
+
task.calendarIds[c.id])} onToggleComplete={onToggleTaskComplete} onSelect={onSelectTask} + touch={isMobile} + tabbable={key === visibleKey} />
))} @@ -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({ }} >
- {formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)} + {formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
)} @@ -649,7 +666,7 @@ export function CalendarDayView({ {pendingPreview.title}
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} + {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
); diff --git a/components/calendar/calendar-search.tsx b/components/calendar/calendar-search.tsx index 283cb8551..b12ef8aef 100644 --- a/components/calendar/calendar-search.tsx +++ b/components/calendar/calendar-search.tsx @@ -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" })} - {ev.showWithoutTime ? t("events.all_day") : `${formatTime(start)} – ${formatTime(end)}`} + {ev.showWithoutTime ? t("events.all_day") : {`${formatTime(start)} – ${formatTime(end)}`}}
- {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)} + {formatSnapTime(startMin, timeFormat)} – {formatSnapTime(startMin + durationMin, timeFormat)}
); diff --git a/components/calendar/display-hours-controls.tsx b/components/calendar/display-hours-controls.tsx index 01cd9ab6a..09ddbe575 100644 --- a/components/calendar/display-hours-controls.tsx +++ b/components/calendar/display-hours-controls.tsx @@ -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 ); } @@ -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 && ( {format(startDate, timeFmt)} )} - {event.title || t("events.no_title")} + {event.title || t("events.no_title")}
); @@ -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. */}
- {event.title || t("events.no_title")} + {event.title || t("events.no_title")} {!event.showWithoutTime && durationMinutes < 45 && ( {safeFormat(startDate, timeFmt)} )} @@ -271,7 +276,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{!event.showWithoutTime && durationMinutes >= 45 && (
- {timeString} + {/* Start before end in a right-to-left layout too. */} + {timeString}
)} {getParticipantCount(event) > 0 && ( diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index b99da1688..5402d6dbb 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -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("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("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. */} + {event.title || t("events.no_title")} {event.status === "tentative" && ( @@ -463,7 +487,7 @@ export function EventDetailPopover({ {t("events.all_day")} ) : (
- {formatTime(startDate)} – {formatTime(endDate)} + {formatTime(startDate)} – {formatTime(endDate)} ({formatDurationDisplay(durationMinutes)})
)} @@ -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({
{participants.slice(0, 5).map((p) => (
- + {p.name || p.email} {p.isOrganizer && ( @@ -575,7 +600,7 @@ export function EventDetailPopover({ {calendar && (
- {calendar.name} + {calendar.name}
)} @@ -583,7 +608,7 @@ export function EventDetailPopover({ {event.description && (
-

+

diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 29967fc5f..645848086 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -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(null); + // The dialog opens on the drop zone, or on the address field for a link. + const dropZoneRef = useRef(null); + const urlInputRef = useRef(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" && ( <> diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 6d3069d4a..fb89b9505 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -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(() => { if (!task?.alerts) return "none"; const first = Object.values(task.alerts)[0]; @@ -205,10 +213,17 @@ export function TaskModal({ }, [requestClose, handleSave]); return ( -
+ // 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. +
{/* Header */}
-

+

{isEdit ? t("tasks.edit") : t("tasks.create")}

@@ -313,7 +328,7 @@ export function TaskModal({
{/* Calendar */} - {writableCalendars.length > 1 && ( + {calendarOptions.length > 1 && (