mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "الإجراءات",
|
||||
"previous": "اليوم أو الأسبوع أو الشهر السابق",
|
||||
"next": "اليوم أو الأسبوع أو الشهر التالي",
|
||||
"new_item": "حدث جديد، أو مهمة جديدة في المهام"
|
||||
"new_item": "حدث جديد، أو مهمة جديدة في المهام",
|
||||
"move_in_grid": "التنقل بين الأيام والساعات في الشبكة"
|
||||
},
|
||||
"other_calendars": "تقويمات أخرى",
|
||||
"calendar_options": "خيارات {name}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "اقدامات",
|
||||
"previous": "روز، هفته یا ماه قبلی",
|
||||
"next": "روز، هفته یا ماه بعدی",
|
||||
"new_item": "رویداد جدید، یا وظیفه جدید در وظایف"
|
||||
"new_item": "رویداد جدید، یا وظیفه جدید در وظایف",
|
||||
"move_in_grid": "جابهجایی بین روزها و ساعتها در جدول"
|
||||
},
|
||||
"other_calendars": "تقویمهای دیگر",
|
||||
"calendar_options": "گزینههای {name}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -3727,7 +3727,8 @@
|
||||
"actions": "פעולות",
|
||||
"previous": "היום, השבוע או החודש הקודם",
|
||||
"next": "היום, השבוע או החודש הבא",
|
||||
"new_item": "אירוע חדש, או משימה חדשה במשימות"
|
||||
"new_item": "אירוע חדש, או משימה חדשה במשימות",
|
||||
"move_in_grid": "מעבר בין ימים ושעות ברשת"
|
||||
},
|
||||
"other_calendars": "יומנים אחרים",
|
||||
"calendar_options": "אפשרויות עבור {name}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "操作",
|
||||
"previous": "前の日・週・月",
|
||||
"next": "次の日・週・月",
|
||||
"new_item": "新しいイベント(タスク画面では新しいタスク)"
|
||||
"new_item": "新しいイベント(タスク画面では新しいタスク)",
|
||||
"move_in_grid": "グリッド内で日と時間を移動"
|
||||
},
|
||||
"other_calendars": "他のカレンダー",
|
||||
"calendar_options": "{name} のオプション"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "작업",
|
||||
"previous": "이전 일/주/월",
|
||||
"next": "다음 일/주/월",
|
||||
"new_item": "새 이벤트 (할 일 보기에서는 새 작업)"
|
||||
"new_item": "새 이벤트 (할 일 보기에서는 새 작업)",
|
||||
"move_in_grid": "그리드에서 날짜와 시간 사이 이동"
|
||||
},
|
||||
"other_calendars": "다른 캘린더",
|
||||
"calendar_options": "{name} 옵션"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "Үйлдлүүд",
|
||||
"previous": "Өмнөх өдөр, долоо хоног эсвэл сар",
|
||||
"next": "Дараах өдөр, долоо хоног эсвэл сар",
|
||||
"new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар"
|
||||
"new_item": "Шинэ арга хэмжээ, Даалгаварууд хэсэгт шинэ даалгавар",
|
||||
"move_in_grid": "Хүснэгтэд өдөр, цагийн хооронд шилжих"
|
||||
},
|
||||
"other_calendars": "Бусад хуанли",
|
||||
"calendar_options": "{name}-ийн тохиргоо"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "Действия",
|
||||
"previous": "Предыдущий день, неделя или месяц",
|
||||
"next": "Следующий день, неделя или месяц",
|
||||
"new_item": "Новое событие, в Задачах — новая задача"
|
||||
"new_item": "Новое событие, в Задачах — новая задача",
|
||||
"move_in_grid": "Перемещение между днями и часами в сетке"
|
||||
},
|
||||
"other_calendars": "Другие календари",
|
||||
"calendar_options": "Параметры: {name}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -3138,7 +3138,8 @@
|
||||
"actions": "การทำงาน",
|
||||
"previous": "วัน สัปดาห์ หรือเดือนก่อนหน้า",
|
||||
"next": "วัน สัปดาห์ หรือเดือนถัดไป",
|
||||
"new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน"
|
||||
"new_item": "กิจกรรมใหม่ หรืองานใหม่ในมุมมองงาน",
|
||||
"move_in_grid": "เลื่อนไปมาระหว่างวันและชั่วโมงในตาราง"
|
||||
},
|
||||
"other_calendars": "ปฏิทินอื่นๆ",
|
||||
"calendar_options": "ตัวเลือกสำหรับ {name}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "Дії",
|
||||
"previous": "Попередній день, тиждень або місяць",
|
||||
"next": "Наступний день, тиждень або місяць",
|
||||
"new_item": "Нова подія, у Завданнях — нове завдання"
|
||||
"new_item": "Нова подія, у Завданнях — нове завдання",
|
||||
"move_in_grid": "Переміщення між днями й годинами в сітці"
|
||||
},
|
||||
"other_calendars": "Інші календарі",
|
||||
"calendar_options": "Параметри: {name}"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "動作",
|
||||
"previous": "上一天、週或月",
|
||||
"next": "下一天、週或月",
|
||||
"new_item": "新增活動(在任務中新增任務)"
|
||||
"new_item": "新增活動(在任務中新增任務)",
|
||||
"move_in_grid": "在格線中按天與小時移動"
|
||||
},
|
||||
"other_calendars": "其他日曆",
|
||||
"calendar_options": "{name} 的選項"
|
||||
|
||||
@@ -3809,7 +3809,8 @@
|
||||
"actions": "操作",
|
||||
"previous": "上一天、周或月",
|
||||
"next": "下一天、周或月",
|
||||
"new_item": "新建事件(在任务中新建任务)"
|
||||
"new_item": "新建事件(在任务中新建任务)",
|
||||
"move_in_grid": "在网格中按天和小时移动"
|
||||
},
|
||||
"other_calendars": "其他日历",
|
||||
"calendar_options": "{name} 的选项"
|
||||
|
||||
Reference in New Issue
Block a user