mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
fix: make the mini calendar a grid with arrow keys, full date names and event days in words
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { MiniCalendar } from '../mini-calendar';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// SR-14: the mini calendar is a real grid, its days are named with the full
|
||||
// date, event days are marked in words and the arrows move between days.
|
||||
|
||||
const event = {
|
||||
id: 'e1',
|
||||
'@type': 'Event',
|
||||
title: 'Dentist',
|
||||
start: '2026-10-29T10:00:00',
|
||||
duration: 'PT1H',
|
||||
showWithoutTime: false,
|
||||
calendarIds: { c1: true },
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
function Harness({ onSelectDate = vi.fn(), onChangeMonth }: { onSelectDate?: (d: Date) => void; onChangeMonth?: (d: Date) => void }) {
|
||||
const [month, setMonth] = useState(new Date(2026, 9, 1));
|
||||
return (
|
||||
<MiniCalendar
|
||||
selectedDate={new Date(2026, 9, 15)}
|
||||
displayMonth={month}
|
||||
onSelectDate={onSelectDate}
|
||||
onChangeMonth={(d) => { onChangeMonth?.(d); setMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }}
|
||||
events={[event]}
|
||||
showWeekNumbers
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const day = (name: RegExp) => screen.getByRole('button', { name });
|
||||
|
||||
describe('MiniCalendar', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('is a grid of rows and cells with weekday column headers', () => {
|
||||
render(<Harness />);
|
||||
const grid = screen.getByRole('grid');
|
||||
expect(within(grid).getAllByRole('columnheader').length).toBe(8);
|
||||
expect(within(grid).getAllByRole('row').length).toBeGreaterThanOrEqual(5);
|
||||
const cell = day(/October 15, 2026/).closest('[role="gridcell"]');
|
||||
expect(cell).toHaveAttribute('aria-selected', 'true');
|
||||
expect(within(grid).getAllByRole('rowheader', { name: /mini_week_number/ }).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('names each day with its full date and says which days have events', () => {
|
||||
render(<Harness />);
|
||||
expect(day(/^Thursday, October 29, 2026, mini_has_events$/)).toBeInTheDocument();
|
||||
expect(day(/^Wednesday, October 28, 2026$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is one tab stop, on the selected day', () => {
|
||||
render(<Harness />);
|
||||
const stops = screen.getByRole('grid').querySelectorAll('button[tabindex="0"]');
|
||||
expect(stops).toHaveLength(1);
|
||||
expect(stops[0]).toBe(day(/October 15, 2026/));
|
||||
});
|
||||
|
||||
it('moves focus with the arrow keys without selecting', () => {
|
||||
const onSelectDate = vi.fn();
|
||||
render(<Harness onSelectDate={onSelectDate} />);
|
||||
day(/October 15, 2026/).focus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
|
||||
expect(document.activeElement).toBe(day(/October 16, 2026/));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(day(/October 23, 2026/));
|
||||
expect(document.activeElement).toHaveAttribute('tabindex', '0');
|
||||
expect(onSelectDate).not.toHaveBeenCalled();
|
||||
fireEvent.click(document.activeElement!);
|
||||
expect(onSelectDate).toHaveBeenCalledWith(new Date(2026, 9, 23));
|
||||
});
|
||||
|
||||
it('turns the month when the arrows leave it and keeps focus on the new day', () => {
|
||||
const onChangeMonth = vi.fn();
|
||||
render(<Harness onChangeMonth={onChangeMonth} />);
|
||||
day(/October 29, 2026/).focus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
|
||||
expect(onChangeMonth).toHaveBeenCalled();
|
||||
expect(screen.getByRole('grid')).toHaveAccessibleName(/November/);
|
||||
expect(document.activeElement).toBe(day(/November 5, 2026/));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'PageUp' });
|
||||
expect(document.activeElement).toBe(day(/October 5, 2026/));
|
||||
});
|
||||
|
||||
it('tells which month is selected in the month picker', () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /October 2026/ }));
|
||||
expect(screen.getByRole('button', { name: 'months.oct' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'months.sep' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, Fragment } from "react";
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from "@/components/icons";
|
||||
import {
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
addDays, addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
getISOWeek, getWeek, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import { displayNow } from "@/lib/timezone";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
|
||||
import { PluginDayLabel } from "./plugin-day-label";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
|
||||
@@ -27,6 +28,8 @@ interface MiniCalendarProps {
|
||||
showWeekNumbers?: boolean;
|
||||
}
|
||||
|
||||
const dayKey = (date: Date) => format(date, "yyyy-MM-dd");
|
||||
|
||||
export function MiniCalendar({
|
||||
selectedDate,
|
||||
displayMonth,
|
||||
@@ -46,16 +49,23 @@ export function MiniCalendar({
|
||||
checkIsSameDay,
|
||||
formatDayNumber,
|
||||
formatMonthYear,
|
||||
formatFullDate,
|
||||
getMonth,
|
||||
getYear,
|
||||
monthLabelKeys,
|
||||
} = useCalendarLocale();
|
||||
const intlFormatter = useDisplayDateFormatter();
|
||||
const [pickerView, setPickerView] = useState<PickerView>("days");
|
||||
|
||||
const days = useMemo(
|
||||
() => getMonthGridDays(displayMonth),
|
||||
[displayMonth, getMonthGridDays],
|
||||
);
|
||||
const weeks = useMemo(() => {
|
||||
const rows: Date[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7));
|
||||
return rows;
|
||||
}, [days]);
|
||||
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -64,7 +74,7 @@ export function MiniCalendar({
|
||||
const { startDay, endDay } = getEventDayBounds(e);
|
||||
const cursor = new Date(startDay);
|
||||
while (cursor <= endDay) {
|
||||
set.add(format(cursor, "yyyy-MM-dd"));
|
||||
set.add(dayKey(cursor));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
@@ -72,16 +82,67 @@ export function MiniCalendar({
|
||||
return set;
|
||||
}, [events]);
|
||||
|
||||
// Compute week numbers for each row (one per 7-day chunk)
|
||||
const weekNumbers = useMemo(() => {
|
||||
if (!showWeekNumbers) return [];
|
||||
const nums: number[] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
// Use the first day of each row to determine the week number
|
||||
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||
const weekNumberOf = (day: Date) =>
|
||||
weekStartsOn === 1 ? getISOWeek(day) : getWeek(day, { weekStartsOn: 0 });
|
||||
|
||||
// The grid is one tab stop (WCAG 2.1.1, ARIA grid pattern): the day the
|
||||
// arrows last moved to, else the selected day, today or the 1st.
|
||||
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||
const pendingFocus = useRef<string | null>(null);
|
||||
const gridRef = useRef<HTMLTableElement>(null);
|
||||
const tabStopKey = useMemo(() => {
|
||||
const inMonth = (day: Date) => checkIsSameMonth(day, displayMonth);
|
||||
const focused = focusedKey ? days.find((day) => dayKey(day) === focusedKey) : undefined;
|
||||
if (focused && inMonth(focused)) return focusedKey!;
|
||||
if (inMonth(selectedDate)) return dayKey(selectedDate);
|
||||
const today = days.find((day) => inMonth(day) && checkIsToday(day));
|
||||
if (today) return dayKey(today);
|
||||
const first = days.find(inMonth);
|
||||
return first ? dayKey(first) : null;
|
||||
}, [focusedKey, days, displayMonth, selectedDate, checkIsSameMonth, checkIsToday]);
|
||||
|
||||
const dayButton = (key: string) =>
|
||||
gridRef.current?.querySelector<HTMLButtonElement>(`[data-mini-day="${key}"]`) ?? null;
|
||||
|
||||
// After moving into another month, focus the day once that month is drawn.
|
||||
useEffect(() => {
|
||||
if (!pendingFocus.current) return;
|
||||
const el = dayButton(pendingFocus.current);
|
||||
pendingFocus.current = null;
|
||||
el?.focus();
|
||||
}, [days]);
|
||||
|
||||
const moveFocus = useCallback((date: Date) => {
|
||||
const key = dayKey(date);
|
||||
setFocusedKey(key);
|
||||
if (checkIsSameMonth(date, displayMonth)) {
|
||||
dayButton(key)?.focus();
|
||||
} else {
|
||||
pendingFocus.current = key;
|
||||
onChangeMonth(date);
|
||||
}
|
||||
return nums;
|
||||
}, [days, showWeekNumbers, weekStartsOn]);
|
||||
}, [checkIsSameMonth, displayMonth, onChangeMonth]);
|
||||
|
||||
const handleDayKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, day: Date, column: number) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
||||
let next: Date | null = null;
|
||||
switch (e.key) {
|
||||
case "ArrowLeft": next = addDays(day, rtl ? 1 : -1); break;
|
||||
case "ArrowRight": next = addDays(day, rtl ? -1 : 1); break;
|
||||
case "ArrowUp": next = addDays(day, -7); break;
|
||||
case "ArrowDown": next = addDays(day, 7); break;
|
||||
case "Home": next = addDays(day, -column); break;
|
||||
case "End": next = addDays(day, 6 - column); break;
|
||||
case "PageUp": next = e.shiftKey ? subYears(day, 1) : subMonths(day, 1); break;
|
||||
case "PageDown": next = e.shiftKey ? addYears(day, 1) : addMonths(day, 1); break;
|
||||
default: return;
|
||||
}
|
||||
// The grid's keys, not the calendar's previous/next period.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveFocus(next);
|
||||
};
|
||||
|
||||
const currentYear = getYear(displayMonth);
|
||||
const currentMonth = getMonth(displayMonth);
|
||||
@@ -120,7 +181,7 @@ export function MiniCalendar({
|
||||
? formatMonthYear(displayMonth)
|
||||
: pickerView === "months"
|
||||
? String(currentYear)
|
||||
: `${decadeStart}\u2013${decadeStart + 9}`;
|
||||
: `${decadeStart}–${decadeStart + 9}`;
|
||||
|
||||
return (
|
||||
<div className="select-none">
|
||||
@@ -136,6 +197,7 @@ export function MiniCalendar({
|
||||
onClick={handleHeaderClick}
|
||||
disabled={pickerView === "years"}
|
||||
title={pickerView !== "years" ? t("mini_calendar_change") : undefined}
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"text-sm font-medium px-2 py-1 rounded-md transition-colors inline-flex items-center gap-1",
|
||||
pickerView !== "years" && "hover:bg-muted cursor-pointer",
|
||||
@@ -144,7 +206,7 @@ export function MiniCalendar({
|
||||
>
|
||||
{headerLabel}
|
||||
{pickerView !== "years" && (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<ChevronDown aria-hidden="true" className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<IconButton
|
||||
@@ -157,71 +219,106 @@ export function MiniCalendar({
|
||||
</div>
|
||||
|
||||
{pickerView === "days" && (
|
||||
<div className={cn("grid gap-0", showWeekNumbers ? "grid-cols-[auto_repeat(7,1fr)]" : "grid-cols-7")}>
|
||||
{showWeekNumbers && (
|
||||
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||
)}
|
||||
{dayHeaderKeys.map((d) => (
|
||||
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||
{t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
{days.map((day, index) => {
|
||||
const inMonth = checkIsSameMonth(day, displayMonth);
|
||||
const selected = checkIsSameDay(day, selectedDate);
|
||||
const today = checkIsToday(day);
|
||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||
const isFirstDayOfRow = index % 7 === 0;
|
||||
|
||||
return (
|
||||
<Fragment key={day.toISOString()}>
|
||||
{showWeekNumbers && isFirstDayOfRow && (
|
||||
<div
|
||||
key={`wk-${index}`}
|
||||
className="flex items-center justify-center w-5 text-[9px] text-muted-foreground/60 font-medium"
|
||||
>
|
||||
{weekNumbers[index / 7]}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
key={`day-${day.toISOString()}`}
|
||||
onClick={() => onSelectDate(day)}
|
||||
className="group/day flex items-center justify-center w-7 h-7 pointer-coarse:w-full pointer-coarse:h-11 text-xs"
|
||||
aria-current={today ? "date" : undefined}
|
||||
aria-pressed={selected}
|
||||
// A real table grid: screen readers read the weekday with each day
|
||||
// and move by row and column; the arrows move between days.
|
||||
<table
|
||||
ref={gridRef}
|
||||
role="grid"
|
||||
aria-label={formatMonthYear(displayMonth)}
|
||||
className="w-full border-collapse table-fixed"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{showWeekNumbers && (
|
||||
<th scope="col" className="w-5 p-0">
|
||||
<span className="sr-only">{t("views.week")}</span>
|
||||
</th>
|
||||
)}
|
||||
{dayHeaderKeys.map((d, index) => (
|
||||
<th
|
||||
key={d}
|
||||
scope="col"
|
||||
abbr={weeks[0]?.[index] ? intlFormatter.dateTime(weeks[0][index], { weekday: "long" }) : undefined}
|
||||
className="text-center text-[10px] font-medium text-muted-foreground py-1"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-7 h-7 rounded-full transition-colors",
|
||||
!inMonth && "text-muted-foreground/40",
|
||||
// Today is the filled circle; the selected day is tinted.
|
||||
today && "bg-primary text-primary-foreground font-semibold",
|
||||
selected && !today && "bg-primary/15 text-primary font-semibold",
|
||||
!today && !selected && "group-hover/day:bg-muted"
|
||||
)}
|
||||
{t(`days.${d}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((week) => (
|
||||
<tr key={dayKey(week[0])}>
|
||||
{showWeekNumbers && (
|
||||
<th
|
||||
scope="row"
|
||||
className="w-5 p-0 text-center text-[9px] text-muted-foreground/60 font-medium"
|
||||
>
|
||||
{formatDayNumber(day)}
|
||||
{hasEvent && !selected && !today && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
|
||||
</span>
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span aria-hidden="true">{weekNumberOf(week[0])}</span>
|
||||
<span className="sr-only">{t("mini_week_number", { week: weekNumberOf(week[0]) })}</span>
|
||||
</th>
|
||||
)}
|
||||
{week.map((day, column) => {
|
||||
const key = dayKey(day);
|
||||
const inMonth = checkIsSameMonth(day, displayMonth);
|
||||
const selected = checkIsSameDay(day, selectedDate);
|
||||
const today = checkIsToday(day);
|
||||
const hasEvent = eventDates.has(key);
|
||||
// "Wednesday, October 29, 2026, has events": the full date,
|
||||
// and events in words, not only as a dot.
|
||||
const label = hasEvent ? `${formatFullDate(day)}, ${t("mini_has_events")}` : formatFullDate(day);
|
||||
return (
|
||||
<td key={key} role="gridcell" aria-selected={selected} className="p-0 text-center">
|
||||
<button
|
||||
type="button"
|
||||
data-mini-day={key}
|
||||
tabIndex={key === tabStopKey ? 0 : -1}
|
||||
onClick={() => { setFocusedKey(key); onSelectDate(day); }}
|
||||
onKeyDown={(e) => handleDayKeyDown(e, day, column)}
|
||||
className="group/day inline-flex items-center justify-center w-7 h-7 pointer-coarse:w-full pointer-coarse:h-11 text-xs"
|
||||
aria-current={today ? "date" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-7 h-7 rounded-full transition-colors",
|
||||
!inMonth && "text-muted-foreground/40",
|
||||
// Today is the filled circle; the selected day is tinted.
|
||||
today && "bg-primary text-primary-foreground font-semibold",
|
||||
selected && !today && "bg-primary/15 text-primary font-semibold",
|
||||
!today && !selected && "group-hover/day:bg-muted"
|
||||
)}
|
||||
>
|
||||
{/* Named by the hidden full date, not the bare number;
|
||||
a plugin's mark below adds its own words. */}
|
||||
<span className="sr-only">{label}</span>
|
||||
<span aria-hidden="true">{formatDayNumber(day)}</span>
|
||||
{hasEvent && !selected && !today && (
|
||||
<span aria-hidden="true" className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
<PluginDayLabel surface="mini-calendar-marks" date={key} />
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{pickerView === "months" && (
|
||||
<div className="grid grid-cols-3 gap-1 py-1">
|
||||
<div className="grid grid-cols-3 gap-1 py-1" role="group" aria-label={String(currentYear)}>
|
||||
{monthLabelKeys.map((labelKey, i) => {
|
||||
const isCurrentMonth = i === currentMonth && currentYear === getYear(displayNow());
|
||||
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handlePickMonth(i)}
|
||||
aria-pressed={isSelected}
|
||||
aria-current={isCurrentMonth ? "date" : undefined}
|
||||
className={cn(
|
||||
"py-2 text-xs rounded-md transition-colors",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
@@ -237,7 +334,7 @@ export function MiniCalendar({
|
||||
)}
|
||||
|
||||
{pickerView === "years" && (
|
||||
<div className="grid grid-cols-3 gap-1 py-1">
|
||||
<div className="grid grid-cols-3 gap-1 py-1" role="group" aria-label={headerLabel}>
|
||||
{years.map((year) => {
|
||||
const inDecade = year >= decadeStart && year <= decadeStart + 9;
|
||||
const isCurrentYear = year === getYear(displayNow());
|
||||
@@ -245,7 +342,10 @@ export function MiniCalendar({
|
||||
return (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
onClick={() => handlePickYear(year)}
|
||||
aria-pressed={isSelected}
|
||||
aria-current={isCurrentYear ? "date" : undefined}
|
||||
className={cn(
|
||||
"py-2 text-xs rounded-md transition-colors",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "يبحث في العناوين والأماكن والأوصاف والضيوف.",
|
||||
"repeats": "متكرر",
|
||||
"results": "{count, plural, zero {لم يُعثر على أي حدث} one {تم العثور على حدث واحد} two {تم العثور على حدثين} few {تم العثور على # أحداث} many {تم العثور على # حدثًا} other {تم العثور على # حدث}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "فيه أحداث",
|
||||
"mini_week_number": "الأسبوع {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "شخص ما",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "La cerca mira títols, llocs, descripcions i convidats.",
|
||||
"repeats": "Es repeteix",
|
||||
"results": "{count, plural, one {# esdeveniment trobat} other {# esdeveniments trobats}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "té esdeveniments",
|
||||
"mini_week_number": "Setmana {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Algú",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Hledá se v názvech, místech, popisech a hostech.",
|
||||
"repeats": "Opakuje se",
|
||||
"results": "{count, plural, one {Nalezena # událost} few {Nalezeny # události} many {Nalezeno # události} other {Nalezeno # událostí}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "má události",
|
||||
"mini_week_number": "Týden {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Pokročilé hledání",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Søgningen ser på titler, steder, beskrivelser og gæster.",
|
||||
"repeats": "Gentages",
|
||||
"results": "{count, plural, one {# begivenhed fundet} other {# begivenheder fundet}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "har begivenheder",
|
||||
"mini_week_number": "Uge {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Nogen",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Gesucht wird in Titeln, Orten, Beschreibungen und Gästen.",
|
||||
"repeats": "Wiederholt sich",
|
||||
"results": "{count, plural, one {# Termin gefunden} other {# Termine gefunden}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "hat Termine",
|
||||
"mini_week_number": "Woche {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Erweiterte Suche",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Search looks at titles, places, descriptions and guests.",
|
||||
"repeats": "Repeats",
|
||||
"results": "{count, plural, one {# event found} other {# events found}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "has events",
|
||||
"mini_week_number": "Week {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Someone",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "La búsqueda mira títulos, lugares, descripciones e invitados.",
|
||||
"repeats": "Se repite",
|
||||
"results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "tiene eventos",
|
||||
"mini_week_number": "Semana {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Búsqueda avanzada",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Otsitakse pealkirjadest, kohtadest, kirjeldustest ja külalistest.",
|
||||
"repeats": "Kordub",
|
||||
"results": "{count, plural, one {Leiti # sündmus} other {Leiti # sündmust}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "on sündmusi",
|
||||
"mini_week_number": "Nädal {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Keegi",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "جستجو در عنوانها، مکانها، توضیحات و مهمانها انجام میشود.",
|
||||
"repeats": "تکرار میشود",
|
||||
"results": "{count, plural, one {# رویداد پیدا شد} other {# رویداد پیدا شد}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "رویداد دارد",
|
||||
"mini_week_number": "هفته {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "کسی",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "La recherche porte sur les titres, lieux, descriptions et invités.",
|
||||
"repeats": "Se répète",
|
||||
"results": "{count, plural, one {# événement trouvé} other {# événements trouvés}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "a des événements",
|
||||
"mini_week_number": "Semaine {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Recherche avancée",
|
||||
|
||||
@@ -4621,7 +4621,9 @@
|
||||
"no_results_hint": "החיפוש בודק כותרות, מקומות, תיאורים ומוזמנים.",
|
||||
"repeats": "חוזר",
|
||||
"results": "{count, plural, one {נמצא אירוע אחד} two {נמצאו שני אירועים} other {נמצאו # אירועים}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "יש אירועים",
|
||||
"mini_week_number": "שבוע {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "חיפוש מתקדם",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "A keresés a címekben, helyszínekben, leírásokban és vendégekben keres.",
|
||||
"repeats": "Ismétlődik",
|
||||
"results": "{count, plural, one {# esemény található} other {# esemény található}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "vannak események",
|
||||
"mini_week_number": "{week}. hét"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Valaki",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "La ricerca guarda titoli, luoghi, descrizioni e invitati.",
|
||||
"repeats": "Si ripete",
|
||||
"results": "{count, plural, one {# evento trovato} other {# eventi trovati}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "ha eventi",
|
||||
"mini_week_number": "Settimana {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Ricerca avanzata",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "タイトル、場所、説明、ゲストを検索します。",
|
||||
"repeats": "繰り返し",
|
||||
"results": "{count, plural, other {# 件の予定が見つかりました}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "イベントあり",
|
||||
"mini_week_number": "第{week}週"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "詳細検索",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "제목, 장소, 설명, 참석자에서 검색합니다.",
|
||||
"repeats": "반복",
|
||||
"results": "{count, plural, other {일정 #개를 찾았습니다}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "일정 있음",
|
||||
"mini_week_number": "{week}주차"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "상세 검색",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Meklē nosaukumos, vietās, aprakstos un viesos.",
|
||||
"repeats": "Atkārtojas",
|
||||
"results": "{count, plural, zero {Atrasti # notikumi} one {Atrasts # notikums} other {Atrasti # notikumi}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "ir notikumi",
|
||||
"mini_week_number": "{week}. nedēļa"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Izvērstā meklēšana",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Гарчиг, газар, тайлбар, зочдоос хайна.",
|
||||
"repeats": "Давтагдана",
|
||||
"results": "{count, plural, one {# үйл явдал олдлоо} other {# үйл явдал олдлоо}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "үйл явдалтай",
|
||||
"mini_week_number": "{week}-р долоо хоног"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Хэн нэгэн",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Søket ser i titler, steder, beskrivelser og gjester.",
|
||||
"repeats": "Gjentas",
|
||||
"results": "{count, plural, one {# hendelse funnet} other {# hendelser funnet}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "har hendelser",
|
||||
"mini_week_number": "Uke {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Noen",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Er wordt gezocht in titels, locaties, beschrijvingen en gasten.",
|
||||
"repeats": "Herhaalt",
|
||||
"results": "{count, plural, one {# afspraak gevonden} other {# afspraken gevonden}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "heeft afspraken",
|
||||
"mini_week_number": "Week {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Geavanceerd zoeken",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Wyszukiwanie obejmuje tytuły, miejsca, opisy i gości.",
|
||||
"repeats": "Powtarza się",
|
||||
"results": "{count, plural, one {Znaleziono # wydarzenie} few {Znaleziono # wydarzenia} many {Znaleziono # wydarzeń} other {Znaleziono # wydarzenia}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "ma wydarzenia",
|
||||
"mini_week_number": "Tydzień {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Wyszukiwanie zaawansowane",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "A pesquisa procura em títulos, locais, descrições e convidados.",
|
||||
"repeats": "Repete-se",
|
||||
"results": "{count, plural, one {# evento encontrado} other {# eventos encontrados}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "tem eventos",
|
||||
"mini_week_number": "Semana {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Pesquisa avançada",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Căutarea se uită la titluri, locuri, descrieri și invitați.",
|
||||
"repeats": "Se repetă",
|
||||
"results": "{count, plural, one {# eveniment găsit} few {# evenimente găsite} other {# de evenimente găsite}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "are evenimente",
|
||||
"mini_week_number": "Săptămâna {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Cineva",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Поиск идёт по названиям, местам, описаниям и гостям.",
|
||||
"repeats": "Повторяется",
|
||||
"results": "{count, plural, one {Найдено # событие} few {Найдено # события} many {Найдено # событий} other {Найдено # события}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "есть события",
|
||||
"mini_week_number": "Неделя {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Расширенный поиск",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Hľadá sa v názvoch, miestach, popisoch a hosťoch.",
|
||||
"repeats": "Opakuje sa",
|
||||
"results": "{count, plural, one {Nájdená # udalosť} few {Nájdené # udalosti} many {Nájdenej # udalosti} other {Nájdených # udalostí}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "má udalosti",
|
||||
"mini_week_number": "Týždeň {week}"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Niekto",
|
||||
|
||||
@@ -3653,7 +3653,9 @@
|
||||
"no_results_hint": "ค้นหาจากชื่อ สถานที่ คำอธิบาย และแขก",
|
||||
"repeats": "เกิดซ้ำ",
|
||||
"results": "{count, plural, other {พบ # กิจกรรม}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "มีกิจกรรม",
|
||||
"mini_week_number": "สัปดาห์ที่ {week}"
|
||||
},
|
||||
"settings": {
|
||||
"title": "การตั้งค่า",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Arama başlıklarda, yerlerde, açıklamalarda ve davetlilerde yapılır.",
|
||||
"repeats": "Tekrarlanıyor",
|
||||
"results": "{count, plural, one {# etkinlik bulundu} other {# etkinlik bulundu}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "etkinlik var",
|
||||
"mini_week_number": "{week}. hafta"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "Birisi",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "Пошук іде за назвами, місцями, описами й гостями.",
|
||||
"repeats": "Повторюється",
|
||||
"results": "{count, plural, one {Знайдено # подію} few {Знайдено # події} many {Знайдено # подій} other {Знайдено # події}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "є події",
|
||||
"mini_week_number": "Тиждень {week}"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Розширений пошук",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "搜尋範圍包括標題、地點、說明和邀請對象。",
|
||||
"repeats": "重複",
|
||||
"results": "{count, plural, other {找到 # 個活動}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "有活動",
|
||||
"mini_week_number": "第 {week} 週"
|
||||
},
|
||||
"calendar_event_notifications": {
|
||||
"someone": "有人",
|
||||
|
||||
@@ -4703,7 +4703,9 @@
|
||||
"no_results_hint": "搜索范围包括标题、地点、描述和邀请对象。",
|
||||
"repeats": "重复",
|
||||
"results": "{count, plural, other {找到 # 个活动}}"
|
||||
}
|
||||
},
|
||||
"mini_has_events": "有事件",
|
||||
"mini_week_number": "第 {week} 周"
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "高级搜索",
|
||||
|
||||
Reference in New Issue
Block a user