fix: reach empty week and day slots from the keyboard with one tab stop, arrow keys and Enter or n to create

This commit is contained in:
Linus Rath
2026-10-28 06:39:15 +01:00
parent 7bfab1cb8a
commit 2da1aa48db
35 changed files with 385 additions and 33 deletions
@@ -0,0 +1,166 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import { CalendarDayView } from '../calendar-day-view';
import { useSettingsStore } from '@/stores/settings-store';
import type { Calendar } from '@/lib/jmap/types';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
const initialSettings = useSettingsStore.getState();
type WeekProps = React.ComponentProps<typeof CalendarWeekView>;
function renderWeek(overrides: Partial<WeekProps> = {}) {
const props: WeekProps = {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'week:2026-09-07',
// Monday 7 Sep to Sunday 13 Sep 2026
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 13),
selectedDate: new Date(2026, 8, 9),
events: [],
calendars,
onSelectDate: vi.fn(),
onSelectEvent: vi.fn(),
onCreateAtTime: vi.fn(),
...overrides,
};
render(<CalendarWeekView {...props} />);
return props;
}
function tabStops(): HTMLElement[] {
return screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0);
}
function focusedSlot(): string | null {
return (document.activeElement as HTMLElement | null)?.getAttribute('data-slot') ?? null;
}
describe('keyboard access to the time grid (CAL-13)', () => {
beforeEach(() => {
useSettingsStore.setState(initialSettings, true);
});
afterEach(() => {
cleanup();
});
it('gives the week grid one tab stop on the selected day', () => {
renderWeek();
const stops = tabStops();
expect(stops).toHaveLength(1);
expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-09T\d+$/);
// The slot is read out with its date and time (the test formatter
// prints the whole Date), not just "Wed 08:00"
expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/);
// ...and one date in the header, not each loaded day's
const dates = screen.getAllByRole('columnheader').map((h) => h.querySelector('button')!);
expect(dates.filter((b) => b.tabIndex === 0)).toHaveLength(1);
});
it('moves by hour and day with the arrows and Home/End', () => {
renderWeek();
const start = tabStops()[0];
start.focus();
fireEvent.keyDown(start, { key: 'Home' });
expect(focusedSlot()).toBe('2026-09-09T8');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
expect(focusedSlot()).toBe('2026-09-09T9');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
expect(focusedSlot()).toBe('2026-09-10T9');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
expect(focusedSlot()).toBe('2026-09-08T9');
fireEvent.keyDown(document.activeElement!, { key: 'End' });
expect(focusedSlot()).toBe('2026-09-08T19');
// The edge of the visible hours stops the arrow
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
expect(focusedSlot()).toBe('2026-09-08T19');
// The tab stop follows the focus
expect(tabStops().map((cell) => cell.getAttribute('data-slot'))).toEqual(['2026-09-08T19']);
});
it('keeps the arrows from paging the calendar', () => {
renderWeek();
const start = tabStops()[0];
start.focus();
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true });
start.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('creates an event at the focused slot with Enter or n', () => {
const props = renderWeek();
const start = tabStops()[0];
start.focus();
fireEvent.keyDown(start, { key: 'Home' });
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 8, 0, 0, 0));
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN' });
expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 9, 0, 0, 0));
// Not with a modifier: Ctrl+N belongs to the browser
fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN', ctrlKey: true });
expect(props.onCreateAtTime).toHaveBeenCalledTimes(2);
});
it('leaves the slots out of the tab order on phones', () => {
renderWeek({ isMobile: true });
expect(tabStops()).toHaveLength(0);
});
it('works the same in the day view', () => {
const onCreateAtTime = vi.fn();
render(
<CalendarDayView
focus={{ date: new Date(2026, 8, 9), nonce: 0 }}
windowKey="day:2026-09-08"
rangeStart={new Date(2026, 8, 8)}
rangeEnd={new Date(2026, 8, 10)}
selectedDate={new Date(2026, 8, 9)}
events={[]}
calendars={calendars}
onSelectEvent={vi.fn()}
onCreateAtTime={onCreateAtTime}
/>,
);
const stops = tabStops();
expect(stops).toHaveLength(1);
expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/);
stops[0].focus();
fireEvent.keyDown(stops[0], { key: 'End' });
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
expect(focusedSlot()).toBe('2026-09-10T19');
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
expect(onCreateAtTime).toHaveBeenCalledWith(new Date(2026, 8, 10, 19, 0, 0, 0));
});
});
describe('quick editor focus (CAL-13)', () => {
afterEach(() => {
cleanup();
});
it('returns focus to the slot it was opened from', async () => {
const { QuickCreatePopover } = await import('../quick-create-popover');
const slot = document.createElement('div');
slot.tabIndex = -1;
document.body.appendChild(slot);
slot.focus();
const { unmount } = render(
<QuickCreatePopover
draft={{ start: new Date(2026, 8, 9, 10), end: new Date(2026, 8, 9, 11), title: '', allDay: false, calendarId: 'cal-1' }}
calendars={calendars}
onChange={vi.fn()}
onSave={vi.fn()}
onMoreOptions={vi.fn()}
onClose={vi.fn()}
/>,
);
(screen.getAllByRole('textbox')[0] as HTMLElement).focus();
unmount();
expect(document.activeElement).toBe(slot);
slot.remove();
});
});
+16 -2
View File
@@ -15,6 +15,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
import { useTimeGridFocus } from "@/hooks/use-time-grid-focus";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -297,6 +298,16 @@ export function CalendarDayView({
onMoveEvent,
});
const { slotProps } = useTimeGridFocus({
rootRef,
headerRef,
gutterWidth,
days,
hours,
selectedDate,
onCreate: onCreateAtTime,
});
const formatHour = (h: number): string => {
if (timeFormat === "12h") {
const d = new Date(2000, 0, 1, h);
@@ -304,6 +315,8 @@ export function CalendarDayView({
}
return format(new Date(2000, 0, 1, h), "HH:mm");
};
// A slot is read out with its whole date: "Wednesday, October 28, 10:00".
const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" });
// Above every in-column overlay (events z-10, handles z-20, drag z-30) so
// columns scrolled past the start do not show through the gutter.
@@ -469,11 +482,12 @@ export function CalendarDayView({
<div
key={h}
role="gridcell"
aria-label={formatHour(h)}
aria-label={`${slotDateLabel(day)}, ${formatHour(h)}`}
{...slotProps(day, h, !isMobile)}
onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
style={{ height: hourHeight }}
/>
))}
+18 -2
View File
@@ -18,6 +18,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
import { useTimeGridFocus } from "@/hooks/use-time-grid-focus";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -393,6 +394,16 @@ export function CalendarWeekView({
onMoveEvent,
});
const { slotProps, tabStopDayKey } = useTimeGridFocus({
rootRef,
headerRef,
gutterWidth,
days,
hours,
selectedDate,
onCreate: onCreateAtTime,
});
const formatHour = (h: number): string => {
if (timeFormat === "12h") {
const d = new Date(2000, 0, 1, h);
@@ -400,6 +411,8 @@ export function CalendarWeekView({
}
return format(new Date(2000, 0, 1, h), "HH:mm");
};
// A slot is read out with its whole date: "Wednesday, October 28, 10:00".
const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" });
// Above every in-column overlay (events z-10, handles z-20, drag z-30) so
// columns scrolled past the start do not show through the gutter.
@@ -451,6 +464,8 @@ export function CalendarWeekView({
<button
type="button"
onClick={() => (onOpenDay ?? onSelectDate)(day)}
// One date in the tab order, not every loaded day's.
tabIndex={dayKey(day) === tabStopDayKey ? 0 : -1}
aria-label={fullLabel}
aria-current={todayCol ? "date" : undefined}
className={cn(
@@ -651,11 +666,12 @@ export function CalendarWeekView({
<div
key={h}
role="gridcell"
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
aria-label={`${slotDateLabel(day)}, ${formatHour(h)}`}
{...slotProps(day, h, !isMobile)}
onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
style={{ height: hourHeight }}
/>
))}
@@ -88,6 +88,18 @@ export function QuickCreatePopover({
return () => cancelAnimationFrame(frame);
}, [startKey, endKey]);
// Focus goes back where it came from (the slot a keyboard user created
// from) when the popover closes, unless something else has taken it.
useEffect(() => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const popover = ref.current;
return () => {
const active = document.activeElement;
const lost = !active || active === document.body || (popover?.contains(active) ?? false);
if (lost && opener?.isConnected) opener.focus({ preventScroll: true });
};
}, []);
// Focus once placed. Chrome sometimes refuses the first focus() right
// after the slot click that opened the popover, so try again briefly.
const placed = position !== null;
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useMemo, useState, type KeyboardEvent, type RefObject } from "react";
import { dayKey } from "@/lib/calendar-scroll-window";
import { calendarShortcutAction } from "@/lib/calendar-shortcuts";
import { displayNow } from "@/lib/timezone";
import type { DisplayHours } from "@/lib/calendar-display-range";
interface UseTimeGridFocusOptions {
/** The grid's scroll container. */
rootRef: RefObject<HTMLElement | null>;
/** The sticky header, which must not cover the focused slot. */
headerRef: RefObject<HTMLElement | null>;
/** Width of the sticky hour gutter at the inline start. */
gutterWidth: number;
days: Date[];
hours: DisplayHours;
/** The day the tab stop starts on while no slot has been focused yet. */
selectedDate: Date;
/** Creates an event at the slot (Enter, `n` or `c`). */
onCreate: (start: Date) => void;
}
export interface TimeGridSlotProps {
"data-slot": string;
tabIndex: number;
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void;
onFocus: () => void;
}
const slotId = (key: string, hour: number) => `${key}T${hour}`;
/**
* Keyboard access to the empty slots of the week and day grids (CAL-13,
* R8): one tab stop for the whole grid, the arrows move by an hour and a
* day, Home and End go to the first and last hour, Enter or `n` creates an
* event there. The month grid works the same way with days.
*/
export function useTimeGridFocus({
rootRef, headerRef, gutterWidth, days, hours, selectedDate, onCreate,
}: UseTimeGridFocusOptions) {
const firstHour = hours.startMinutes / 60;
const lastHour = hours.endMinutes / 60 - 1;
const [focused, setFocused] = useState<{ key: string; hour: number } | null>(null);
const dayIndexByKey = useMemo(() => new Map(days.map((day, index) => [dayKey(day), index])), [days]);
// The slot that takes Tab: the last one focused while it is still drawn,
// else the selected day (or the first loaded one) at the current hour.
const tabStop = useMemo(() => {
if (focused && dayIndexByKey.has(focused.key) && focused.hour >= firstHour && focused.hour <= lastHour) {
return focused;
}
const selectedKey = dayKey(selectedDate);
const key = dayIndexByKey.has(selectedKey) ? selectedKey : days[0] ? dayKey(days[0]) : selectedKey;
const hour = Math.min(lastHour, Math.max(firstHour, displayNow().getHours()));
return { key, hour };
}, [focused, dayIndexByKey, firstHour, lastHour, selectedDate, days]);
const focusSlot = useCallback((key: string, hour: number) => {
const root = rootRef.current;
const el = root?.querySelector<HTMLElement>(`[data-slot="${slotId(key, hour)}"]`);
if (!el) return;
setFocused({ key, hour });
// Clear of the sticky header and hour gutter.
el.style.scrollMarginTop = `${headerRef.current?.offsetHeight ?? 0}px`;
el.style.scrollMarginInlineStart = `${gutterWidth}px`;
el.focus({ preventScroll: true });
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}, [rootRef, headerRef, gutterWidth]);
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLElement>, day: Date, hour: number) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const key = dayKey(day);
const index = dayIndexByKey.get(key) ?? 0;
if (e.key === "Enter" || calendarShortcutAction(e.nativeEvent) === "new") {
// The slot's own create, not the calendar's `n` at the selected day.
e.preventDefault();
const start = new Date(day);
start.setHours(hour, 0, 0, 0);
onCreate(start);
return;
}
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
let nextIndex = index;
let nextHour = hour;
switch (e.key) {
case "ArrowUp": nextHour = hour - 1; break;
case "ArrowDown": nextHour = hour + 1; break;
case "ArrowLeft": nextIndex = index + (rtl ? 1 : -1); break;
case "ArrowRight": nextIndex = index + (rtl ? -1 : 1); break;
case "Home": nextHour = firstHour; break;
case "End": nextHour = lastHour; break;
default: return;
}
// The grid's arrows, not the calendar's previous/next period.
e.preventDefault();
const nextDay = days[nextIndex];
if (!nextDay || nextHour < firstHour || nextHour > lastHour) return;
focusSlot(dayKey(nextDay), nextHour);
}, [dayIndexByKey, days, firstHour, lastHour, focusSlot, onCreate]);
/** Props for the slot of `day` at `hour`; pass `enabled: false` on phones. */
const slotProps = useCallback((day: Date, hour: number, enabled = true): TimeGridSlotProps => {
const key = dayKey(day);
return {
"data-slot": slotId(key, hour),
tabIndex: enabled && key === tabStop.key && hour === tabStop.hour ? 0 : -1,
onKeyDown: (e) => handleKeyDown(e, day, hour),
onFocus: () => setFocused((prev) => (prev?.key === key && prev.hour === hour ? prev : { key, hour })),
};
}, [tabStop, handleKeyDown]);
/** The day of the tab stop: the header's date buttons take Tab on that day only. */
return { slotProps, tabStopDayKey: tabStop.key };
}
+1
View File
@@ -28,6 +28,7 @@ export const CALENDAR_SECTIONS: readonly ShortcutSection[] = [
id: "actions",
title: "calendar.shortcuts.actions",
entries: [
{ key: "↑ ↓ ← →", description: "calendar.shortcuts.move_in_grid" },
{ key: "n / c", description: "calendar.shortcuts.new_item" },
{ key: "e", description: "calendar.events.edit" },
{ key: "Mod + Enter", description: "calendar.form.save" },
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "الإجراءات",
"previous": "اليوم أو الأسبوع أو الشهر السابق",
"next": "اليوم أو الأسبوع أو الشهر التالي",
"new_item": "حدث جديد، أو مهمة جديدة في المهام"
"new_item": "حدث جديد، أو مهمة جديدة في المهام",
"move_in_grid": "التنقل بين الأيام والساعات في الشبكة"
},
"other_calendars": "تقويمات أخرى",
"calendar_options": "خيارات {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Accions",
"previous": "Dia, setmana o mes anterior",
"next": "Dia, setmana o mes següent",
"new_item": "Esdeveniment nou, o una tasca nova a Tasques"
"new_item": "Esdeveniment nou, o una tasca nova a Tasques",
"move_in_grid": "Moure's entre dies i hores a la quadrícula"
},
"other_calendars": "Altres calendaris",
"calendar_options": "Opcions de {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Akce",
"previous": "Předchozí den, týden nebo měsíc",
"next": "Další den, týden nebo měsíc",
"new_item": "Nová událost, v Úkolech nový úkol"
"new_item": "Nová událost, v Úkolech nový úkol",
"move_in_grid": "Přesun mezi dny a hodinami v mřížce"
},
"other_calendars": "Další kalendáře",
"calendar_options": "Možnosti pro {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Handlinger",
"previous": "Forrige dag, uge eller måned",
"next": "Næste dag, uge eller måned",
"new_item": "Ny begivenhed, eller en ny opgave i Opgaver"
"new_item": "Ny begivenhed, eller en ny opgave i Opgaver",
"move_in_grid": "Gå mellem dage og timer i gitteret"
},
"other_calendars": "Andre kalendere",
"calendar_options": "Indstillinger for {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Aktionen",
"previous": "Zurück um einen Tag, eine Woche oder einen Monat",
"next": "Weiter um einen Tag, eine Woche oder einen Monat",
"new_item": "Neuer Termin, in Aufgaben eine neue Aufgabe"
"new_item": "Neuer Termin, in Aufgaben eine neue Aufgabe",
"move_in_grid": "Im Raster zwischen Tagen und Stunden wechseln"
},
"other_calendars": "Weitere Kalender",
"calendar_options": "Optionen für {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Actions",
"previous": "Previous day, week or month",
"next": "Next day, week or month",
"new_item": "New event, or a task in Tasks"
"new_item": "New event, or a task in Tasks",
"move_in_grid": "Move between days and hours in the grid"
},
"other_calendars": "Other calendars",
"calendar_options": "Options for {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Acciones",
"previous": "Día, semana o mes anterior",
"next": "Día, semana o mes siguiente",
"new_item": "Nuevo evento, o una tarea nueva en Tareas"
"new_item": "Nuevo evento, o una tarea nueva en Tareas",
"move_in_grid": "Moverse entre días y horas en la cuadrícula"
},
"other_calendars": "Otros calendarios",
"calendar_options": "Opciones de {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Toimingud",
"previous": "Eelmine päev, nädal või kuu",
"next": "Järgmine päev, nädal või kuu",
"new_item": "Uus sündmus, ülesannetes uus ülesanne"
"new_item": "Uus sündmus, ülesannetes uus ülesanne",
"move_in_grid": "Liigu ruudustikus päevade ja tundide vahel"
},
"other_calendars": "Muud kalendrid",
"calendar_options": "Kalendri {name} valikud"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "اقدامات",
"previous": "روز، هفته یا ماه قبلی",
"next": "روز، هفته یا ماه بعدی",
"new_item": "رویداد جدید، یا وظیفه جدید در وظایف"
"new_item": "رویداد جدید، یا وظیفه جدید در وظایف",
"move_in_grid": "جابه‌جایی بین روزها و ساعت‌ها در جدول"
},
"other_calendars": "تقویم‌های دیگر",
"calendar_options": "گزینه‌های {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Actions",
"previous": "Jour, semaine ou mois précédent",
"next": "Jour, semaine ou mois suivant",
"new_item": "Nouvel événement, ou une nouvelle tâche dans Tâches"
"new_item": "Nouvel événement, ou une nouvelle tâche dans Tâches",
"move_in_grid": "Se déplacer entre les jours et les heures dans la grille"
},
"other_calendars": "Autres agendas",
"calendar_options": "Options de {name}"
+2 -1
View File
@@ -3727,7 +3727,8 @@
"actions": "פעולות",
"previous": "היום, השבוע או החודש הקודם",
"next": "היום, השבוע או החודש הבא",
"new_item": "אירוע חדש, או משימה חדשה במשימות"
"new_item": "אירוע חדש, או משימה חדשה במשימות",
"move_in_grid": "מעבר בין ימים ושעות ברשת"
},
"other_calendars": "יומנים אחרים",
"calendar_options": "אפשרויות עבור {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Műveletek",
"previous": "Előző nap, hét vagy hónap",
"next": "Következő nap, hét vagy hónap",
"new_item": "Új esemény, a Feladatokban új feladat"
"new_item": "Új esemény, a Feladatokban új feladat",
"move_in_grid": "Mozgás a napok és órák között a rácsban"
},
"other_calendars": "Egyéb naptárak",
"calendar_options": "{name} beállításai"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Azioni",
"previous": "Giorno, settimana o mese precedente",
"next": "Giorno, settimana o mese successivo",
"new_item": "Nuovo evento, o una nuova attività in Attività"
"new_item": "Nuovo evento, o una nuova attività in Attività",
"move_in_grid": "Spostarsi tra giorni e ore nella griglia"
},
"other_calendars": "Altri calendari",
"calendar_options": "Opzioni per {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "操作",
"previous": "前の日・週・月",
"next": "次の日・週・月",
"new_item": "新しいイベント(タスク画面では新しいタスク)"
"new_item": "新しいイベント(タスク画面では新しいタスク)",
"move_in_grid": "グリッド内で日と時間を移動"
},
"other_calendars": "他のカレンダー",
"calendar_options": "{name} のオプション"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "작업",
"previous": "이전 일/주/월",
"next": "다음 일/주/월",
"new_item": "새 이벤트 (할 일 보기에서는 새 작업)"
"new_item": "새 이벤트 (할 일 보기에서는 새 작업)",
"move_in_grid": "그리드에서 날짜와 시간 사이 이동"
},
"other_calendars": "다른 캘린더",
"calendar_options": "{name} 옵션"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Darbības",
"previous": "Iepriekšējā diena, nedēļa vai mēnesis",
"next": "Nākamā diena, nedēļa vai mēnesis",
"new_item": "Jauns notikums vai, sadaļā Uzdevumi, jauns uzdevums"
"new_item": "Jauns notikums vai, sadaļā Uzdevumi, jauns uzdevums",
"move_in_grid": "Pārvietoties starp dienām un stundām režģī"
},
"other_calendars": "Citi kalendāri",
"calendar_options": "{name} opcijas"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Үйлдлүүд",
"previous": "Өмнөх өдөр, долоо хоног эсвэл сар",
"next": "Дараах өдөр, долоо хоног эсвэл сар",
"new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар"
"new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар",
"move_in_grid": "Хүснэгтэд өдөр, цагийн хооронд шилжих"
},
"other_calendars": "Бусад хуанли",
"calendar_options": "{name}-ийн тохиргоо"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Handlinger",
"previous": "Forrige dag, uke eller måned",
"next": "Neste dag, uke eller måned",
"new_item": "Ny hendelse, eller en ny oppgave i Oppgaver"
"new_item": "Ny hendelse, eller en ny oppgave i Oppgaver",
"move_in_grid": "Gå mellom dager og timer i rutenettet"
},
"other_calendars": "Andre kalendere",
"calendar_options": "Alternativer for {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Acties",
"previous": "Vorige dag, week of maand",
"next": "Volgende dag, week of maand",
"new_item": "Nieuwe afspraak, of een nieuwe taak in Taken"
"new_item": "Nieuwe afspraak, of een nieuwe taak in Taken",
"move_in_grid": "Tussen dagen en uren in het raster bewegen"
},
"other_calendars": "Andere agenda's",
"calendar_options": "Opties voor {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Akcje",
"previous": "Poprzedni dzień, tydzień lub miesiąc",
"next": "Następny dzień, tydzień lub miesiąc",
"new_item": "Nowe wydarzenie lub, w Zadaniach, nowe zadanie"
"new_item": "Nowe wydarzenie lub, w Zadaniach, nowe zadanie",
"move_in_grid": "Przechodzenie między dniami i godzinami w siatce"
},
"other_calendars": "Inne kalendarze",
"calendar_options": "Opcje dla {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Ações",
"previous": "Dia, semana ou mês anterior",
"next": "Dia, semana ou mês seguinte",
"new_item": "Novo evento, ou uma nova tarefa em Tarefas"
"new_item": "Novo evento, ou uma nova tarefa em Tarefas",
"move_in_grid": "Mover entre dias e horas na grelha"
},
"other_calendars": "Outros calendários",
"calendar_options": "Opções de {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Acțiuni",
"previous": "Ziua, săptămâna sau luna anterioară",
"next": "Ziua, săptămâna sau luna următoare",
"new_item": "Eveniment nou sau, în Sarcini, o sarcină nouă"
"new_item": "Eveniment nou sau, în Sarcini, o sarcină nouă",
"move_in_grid": "Deplasare între zile și ore în grilă"
},
"other_calendars": "Alte calendare",
"calendar_options": "Opțiuni pentru {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Действия",
"previous": "Предыдущий день, неделя или месяц",
"next": "Следующий день, неделя или месяц",
"new_item": "Новое событие, в Задачах — новая задача"
"new_item": "Новое событие, в Задачах — новая задача",
"move_in_grid": "Перемещение между днями и часами в сетке"
},
"other_calendars": "Другие календари",
"calendar_options": "Параметры: {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Akcie",
"previous": "Predchádzajúci deň, týždeň alebo mesiac",
"next": "Nasledujúci deň, týždeň alebo mesiac",
"new_item": "Nová udalosť, v Úlohách nová úloha"
"new_item": "Nová udalosť, v Úlohách nová úloha",
"move_in_grid": "Presun medzi dňami a hodinami v mriežke"
},
"other_calendars": "Ďalšie kalendáre",
"calendar_options": "Možnosti pre {name}"
+2 -1
View File
@@ -3138,7 +3138,8 @@
"actions": "การทำงาน",
"previous": "วัน สัปดาห์ หรือเดือนก่อนหน้า",
"next": "วัน สัปดาห์ หรือเดือนถัดไป",
"new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน"
"new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน",
"move_in_grid": "เลื่อนไปมาระหว่างวันและชั่วโมงในตาราง"
},
"other_calendars": "ปฏิทินอื่นๆ",
"calendar_options": "ตัวเลือกสำหรับ {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Eylemler",
"previous": "Önceki gün, hafta veya ay",
"next": "Sonraki gün, hafta veya ay",
"new_item": "Yeni etkinlik, Görevler'de yeni görev"
"new_item": "Yeni etkinlik, Görevler'de yeni görev",
"move_in_grid": "Izgarada günler ve saatler arasında gezin"
},
"other_calendars": "Diğer takvimler",
"calendar_options": "{name} seçenekleri"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "Дії",
"previous": "Попередній день, тиждень або місяць",
"next": "Наступний день, тиждень або місяць",
"new_item": "Нова подія, у Завданнях — нове завдання"
"new_item": "Нова подія, у Завданнях — нове завдання",
"move_in_grid": "Переміщення між днями й годинами в сітці"
},
"other_calendars": "Інші календарі",
"calendar_options": "Параметри: {name}"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "動作",
"previous": "上一天、週或月",
"next": "下一天、週或月",
"new_item": "新增活動(在任務中新增任務)"
"new_item": "新增活動(在任務中新增任務)",
"move_in_grid": "在格線中按天與小時移動"
},
"other_calendars": "其他日曆",
"calendar_options": "{name} 的選項"
+2 -1
View File
@@ -3809,7 +3809,8 @@
"actions": "操作",
"previous": "上一天、周或月",
"next": "下一天、周或月",
"new_item": "新建事件(在任务中新建任务)"
"new_item": "新建事件(在任务中新建任务)",
"move_in_grid": "在网格中按天和小时移动"
},
"other_calendars": "其他日历",
"calendar_options": "{name} 的选项"