mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: translate the calendar sheets and label their buttons
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
This commit is contained in:
@@ -16,6 +16,18 @@ const TRANSLATED_FILES: Record<string, string[]> = {
|
||||
'components/calendar/MonthView.tsx': [],
|
||||
'components/calendar/TimeGridScrollView.tsx': [],
|
||||
'components/calendar/WeekView.tsx': [],
|
||||
'components/calendar/CalendarEditSheet.tsx': [],
|
||||
'components/calendar/CalendarShareSheet.tsx': [],
|
||||
'components/calendar/CalendarSidebarDrawer.tsx': [],
|
||||
'components/calendar/EventDetailSheet.tsx': [],
|
||||
// The meeting link placeholder shows an example URL.
|
||||
'components/calendar/EventModal.tsx': ['https://meet.example.com/…'],
|
||||
'components/calendar/ICalImportSheet.tsx': [],
|
||||
'components/calendar/ICalSubscriptionSheet.tsx': [],
|
||||
'components/calendar/ParticipantInput.tsx': [],
|
||||
'components/calendar/RecurrenceEditor.tsx': [],
|
||||
'components/calendar/RecurrenceScopeDialog.tsx': [],
|
||||
'components/calendar/TasksSheet.tsx': [],
|
||||
};
|
||||
|
||||
describe('translated calendar screens', () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import Button from '../Button';
|
||||
|
||||
export interface CalendarEditValues {
|
||||
@@ -98,6 +98,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
key={col}
|
||||
onPress={() => setColor(col)}
|
||||
style={[styles.swatch, { backgroundColor: col }, active && styles.swatchActive]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={calendarColorName(col, t)}
|
||||
accessibilityState={{ selected: active }}
|
||||
>
|
||||
{active && <Check size={12} color={c.textInverse} />}
|
||||
</Pressable>
|
||||
|
||||
@@ -40,11 +40,12 @@ const CALENDAR_PRESETS: Record<RolePreset, CalendarRights> = {
|
||||
},
|
||||
};
|
||||
|
||||
// The webmail's sharing presets (same rights, same names).
|
||||
const PRESET_LABEL_KEYS: Record<RolePreset, [string, string]> = {
|
||||
freeBusy: ['calendar.share.role_free_busy', 'Free/busy'],
|
||||
read: ['calendar.share.role_viewer', 'Viewer'],
|
||||
readWrite: ['calendar.share.role_editor', 'Editor'],
|
||||
manager: ['calendar.share.role_manager', 'Manager'],
|
||||
freeBusy: ['sharing.preset.freeBusy', 'Free/busy only'],
|
||||
read: ['sharing.preset.read', 'Read only'],
|
||||
readWrite: ['sharing.preset.readWrite', 'Read & write'],
|
||||
manager: ['sharing.preset.manager', 'Manager'],
|
||||
};
|
||||
|
||||
const PRESET_ORDER: RolePreset[] = ['freeBusy', 'read', 'readWrite', 'manager'];
|
||||
@@ -159,7 +160,7 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
||||
<View style={styles.titleRow}>
|
||||
<Users size={18} color={c.textMuted} />
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{t('calendar.share.title', 'Share')} “{calendar.name}”
|
||||
{t('sharing.title', 'Share "{name}"', { name: calendar.name })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -180,6 +181,8 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
||||
onPress={() => void applyShare(principalId, CALENDAR_PRESETS[p])}
|
||||
disabled={busy}
|
||||
style={[styles.chip, preset === p && styles.chipActive]}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: preset === p, disabled: busy }}
|
||||
>
|
||||
<Text style={[styles.chipText, preset === p && styles.chipTextActive]}>
|
||||
{t(PRESET_LABEL_KEYS[p][0], PRESET_LABEL_KEYS[p][1])}
|
||||
@@ -187,13 +190,15 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
||||
</Pressable>
|
||||
))}
|
||||
{preset === 'custom' ? (
|
||||
<Text style={styles.customLabel}>{t('calendar.share.role_custom', 'Custom')}</Text>
|
||||
<Text style={styles.customLabel}>{t('sharing.preset.custom', 'Custom')}</Text>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={() => void applyShare(principalId, null)}
|
||||
disabled={busy}
|
||||
hitSlop={8}
|
||||
style={styles.removeBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('sharing.remove', 'Remove access')}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
import { CALENDAR_COLOR_PALETTE, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||
import { isWritableCalendar } from '../../lib/calendar-editability';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
@@ -152,7 +152,13 @@ export function CalendarSidebarDrawer({
|
||||
>
|
||||
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerClose} hitSlop={8}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.headerClose}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{t('calendar.my_calendars', 'Calendars')}</Text>
|
||||
@@ -274,6 +280,8 @@ function Section({
|
||||
styles.row,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: visible }}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
@@ -328,6 +336,9 @@ function Section({
|
||||
{ backgroundColor: color },
|
||||
active && styles.paletteSwatchActive,
|
||||
]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={calendarColorName(color, t)}
|
||||
accessibilityState={{ selected: active }}
|
||||
>
|
||||
{active && <Check size={12} color={c.textInverse} />}
|
||||
</Pressable>
|
||||
|
||||
@@ -52,6 +52,8 @@ import { getEventEditability } from '../../lib/calendar-editability';
|
||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
|
||||
import type { TranslateFn } from '../../stores/locale-store';
|
||||
|
||||
type RsvpStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
@@ -100,30 +102,14 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
|
||||
return `${format(start, `MMM d, ${tp}`, opts)} – ${format(end, `MMM d, ${tp}`, opts)}`;
|
||||
}
|
||||
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
|
||||
function recurrenceLabel(event: CalendarEvent, t: Translate, locale: Locale): string | null {
|
||||
function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null {
|
||||
const rule = event.recurrenceRules?.[0];
|
||||
if (!rule) return null;
|
||||
const freq = rule.frequency.toLowerCase();
|
||||
const unitKey: Record<string, [string, string]> = {
|
||||
daily: ['calendar.recurrence.every_n_days', 'Every {count} days'],
|
||||
weekly: ['calendar.recurrence.every_n_weeks', 'Every {count} weeks'],
|
||||
monthly: ['calendar.recurrence.every_n_months', 'Every {count} months'],
|
||||
yearly: ['calendar.recurrence.every_n_years', 'Every {count} years'],
|
||||
};
|
||||
let label: string;
|
||||
if (rule.interval && rule.interval > 1 && unitKey[freq]) {
|
||||
label = t(unitKey[freq][0], unitKey[freq][1]).replace('{count}', String(rule.interval));
|
||||
} else {
|
||||
label = t(`calendar.recurrence.${freq}`, rule.frequency);
|
||||
}
|
||||
if (rule.until) {
|
||||
label += ` · ${t('calendar.recurrence.until', 'Until')} ${format(new Date(rule.until), 'MMM d, yyyy', { locale })}`;
|
||||
} else if (rule.count) {
|
||||
label += ` · ${t('calendar.recurrence.occurrences', '{count} occurrences').replace('{count}', String(rule.count))}`;
|
||||
}
|
||||
return label;
|
||||
const label = recurrenceIntervalLabel(freq, rule.interval ?? 1, t)
|
||||
?? t(`calendar.recurrence.${freq}`, rule.frequency);
|
||||
const end = recurrenceEndLabel(rule, t, locale);
|
||||
return end ? `${label} · ${end}` : label;
|
||||
}
|
||||
|
||||
export function EventDetailSheet({
|
||||
@@ -257,7 +243,13 @@ export function EventDetailSheet({
|
||||
</Text>
|
||||
{calendar && <Text style={styles.subtitle}>{calendar.name}</Text>}
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -362,7 +354,7 @@ export function EventDetailSheet({
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.participantName} numberOfLines={1}>
|
||||
{p.name || p.email || 'Unknown'}
|
||||
{p.name || p.email || t('common.unknown', 'Unknown')}
|
||||
{p.isOrganizer ? (
|
||||
<Text style={styles.participantOrganizer}>
|
||||
{' '}({t('calendar.participants.organizer', 'Organizer').toLowerCase()})
|
||||
@@ -520,7 +512,7 @@ function statusColor(c: ThemePalette, status?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string | undefined, t: Translate): string {
|
||||
function statusLabel(status: string | undefined, t: TranslateFn): string {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return t('calendar.participants.accepted', 'Accepted');
|
||||
|
||||
@@ -387,7 +387,7 @@ export function EventModal({
|
||||
const recurrenceOption = RECURRENCE_OPTIONS.find((o) => o.value === recurrence);
|
||||
const recurrenceLabel =
|
||||
recurrence === 'custom'
|
||||
? (customRule && buildRecurrenceSummary(customRule)) || t('calendar.recurrence.custom', 'Custom…')
|
||||
? (customRule && buildRecurrenceSummary(customRule, t, locale)) || t('calendar.recurrence.custom', 'Custom…')
|
||||
: recurrenceOption
|
||||
? t(recurrenceOption.key, recurrenceOption.fallback)
|
||||
: t('calendar.recurrence.none', 'Does not repeat');
|
||||
@@ -416,7 +416,13 @@ export function EventModal({
|
||||
>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={8}
|
||||
style={styles.headerBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>
|
||||
@@ -501,6 +507,7 @@ export function EventModal({
|
||||
<Switch
|
||||
value={allDay}
|
||||
onValueChange={setAllDay}
|
||||
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
|
||||
thumbColor={allDay ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
@@ -608,6 +615,8 @@ export function EventModal({
|
||||
onPress={() => removeReminder(r.minutesBefore)}
|
||||
hitSlop={8}
|
||||
style={styles.reminderRemove}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.alerts.remove', 'Remove reminder')}
|
||||
>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
@@ -739,6 +748,7 @@ export function EventModal({
|
||||
<Switch
|
||||
value={sendInvitations}
|
||||
onValueChange={setSendInvitations}
|
||||
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
|
||||
@@ -192,7 +192,13 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t('calendar.import.title', 'Import Calendar')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={8}
|
||||
style={styles.close}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { CALENDAR_COLOR_PALETTE } from '../../lib/calendar-utils';
|
||||
import { CALENDAR_COLOR_PALETTE, calendarColorName } from '../../lib/calendar-utils';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import {
|
||||
DEFAULT_REFRESH_INTERVAL_MINUTES,
|
||||
selectAccountSubscriptions,
|
||||
@@ -17,11 +18,12 @@ import {
|
||||
} from '../../stores/calendar-subscriptions-store';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
|
||||
const INTERVAL_OPTIONS: { minutes: number; label: string }[] = [
|
||||
{ minutes: 15, label: '15 min' },
|
||||
{ minutes: 60, label: '1 h' },
|
||||
{ minutes: 360, label: '6 h' },
|
||||
{ minutes: 1440, label: '1 day' },
|
||||
// The webmail's refresh intervals and their labels.
|
||||
const INTERVAL_OPTIONS: { minutes: number; key: string; fallback: string }[] = [
|
||||
{ minutes: 15, key: 'calendar.subscription.interval_15', fallback: 'Every 15 minutes' },
|
||||
{ minutes: 60, key: 'calendar.subscription.interval_60', fallback: 'Every hour' },
|
||||
{ minutes: 360, key: 'calendar.subscription.interval_360', fallback: 'Every 6 hours' },
|
||||
{ minutes: 1440, key: 'calendar.subscription.interval_1440', fallback: 'Every day' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -33,6 +35,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { locale, t } = useCalendarLocale();
|
||||
const allSubscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
// Only the signed-in account's feeds: a sub created under another account
|
||||
// mirrors into a calendar id that doesn't exist (or collides) here.
|
||||
@@ -98,11 +101,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
const n = name.trim();
|
||||
const u = url.trim();
|
||||
if (!n || !u) {
|
||||
setFormError('Enter a name and a feed URL.');
|
||||
setFormError(t('calendar.subscription.name_and_url_required', 'Enter a name and a feed URL.'));
|
||||
return;
|
||||
}
|
||||
if (!/^(https?|webcals?):\/\//i.test(u)) {
|
||||
setFormError('URL must start with https://, http://, webcal:// or webcals://');
|
||||
setFormError(t('calendar.subscription.url_scheme_invalid', 'URL must start with https://, http://, webcal:// or webcals://'));
|
||||
return;
|
||||
}
|
||||
setFormError(null);
|
||||
@@ -115,7 +118,13 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
}
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : 'Could not add subscription.');
|
||||
setFormError(
|
||||
e instanceof Error && e.message
|
||||
? e.message
|
||||
: editingId
|
||||
? t('calendar.subscription.update_error', 'Failed to update subscription')
|
||||
: t('calendar.subscription.error', 'Failed to add subscription'),
|
||||
);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
@@ -130,26 +139,38 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
<SafeAreaView edges={['bottom']} style={{ flex: 1 }}>
|
||||
<View style={styles.header}>
|
||||
<Rss size={20} color={c.text} />
|
||||
<Text style={styles.title}>Calendar subscriptions</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<Text style={styles.title}>{t('calendar.subscription.section_title', 'iCal Subscriptions')}</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={8}
|
||||
style={styles.close}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={styles.scroll}>
|
||||
<Text style={styles.sectionLabel}>{editingId ? 'Edit subscription' : 'Add a feed'}</Text>
|
||||
<Text style={styles.sectionLabel}>
|
||||
{editingId
|
||||
? t('calendar.subscription.edit_title', 'Edit Subscription')
|
||||
: t('calendar.subscription.add_feed', 'Add a feed')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Name (e.g. Holidays)"
|
||||
placeholder={t('calendar.subscription.name_placeholder', 'e.g. Public Holidays')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
accessibilityLabel={t('calendar.subscription.name_label', 'Calendar name')}
|
||||
/>
|
||||
<TextInput
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://… or webcal://…"
|
||||
placeholder={t('calendar.subscription.url_placeholder', 'https://example.com/calendar.ics or webcal://...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
accessibilityLabel={t('calendar.subscription.url_label', 'Calendar URL')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
@@ -165,10 +186,13 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
{ backgroundColor: col },
|
||||
color === col && styles.colorDotActive,
|
||||
]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={calendarColorName(col, t)}
|
||||
accessibilityState={{ selected: color === col }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.fieldLabel}>Refresh every</Text>
|
||||
<Text style={styles.fieldLabel}>{t('calendar.subscription.refresh_interval', 'Refresh interval')}</Text>
|
||||
<View style={styles.intervalRow}>
|
||||
{INTERVAL_OPTIONS.map((opt) => {
|
||||
const active = interval === opt.minutes;
|
||||
@@ -177,9 +201,11 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
key={opt.minutes}
|
||||
onPress={() => setInterval(opt.minutes)}
|
||||
style={[styles.intervalChip, active && styles.intervalChipActive]}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected: active }}
|
||||
>
|
||||
<Text style={[styles.intervalChipText, active && styles.intervalChipTextActive]}>
|
||||
{opt.label}
|
||||
{t(opt.key, opt.fallback)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -194,7 +220,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
<View style={styles.formActions}>
|
||||
{editingId && (
|
||||
<Button variant="outline" onPress={resetForm} disabled={adding}>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -202,13 +228,21 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
disabled={adding}
|
||||
icon={adding ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Plus size={16} color={c.primaryForeground} />}
|
||||
>
|
||||
{adding ? (editingId ? 'Saving…' : 'Subscribing…') : editingId ? 'Save changes' : 'Subscribe'}
|
||||
{adding
|
||||
? (editingId
|
||||
? t('calendar.subscription.saving', 'Saving...')
|
||||
: t('calendar.subscription.subscribing', 'Subscribing...'))
|
||||
: editingId
|
||||
? t('calendar.subscription.save', 'Save changes')
|
||||
: t('calendar.subscription.subscribe', 'Subscribe')}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{subscriptions.length > 0 && (
|
||||
<>
|
||||
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>Your subscriptions</Text>
|
||||
<Text style={[styles.sectionLabel, { marginTop: spacing.xl }]}>
|
||||
{t('calendar.subscription.your_subscriptions', 'Your subscriptions')}
|
||||
</Text>
|
||||
{subscriptions.map((sub) => {
|
||||
const busy = !!syncing[sub.id];
|
||||
return (
|
||||
@@ -220,8 +254,10 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
{sub.lastError
|
||||
? sub.lastError
|
||||
: sub.lastSyncAt
|
||||
? `Synced ${formatDistanceToNow(sub.lastSyncAt, { addSuffix: true })}`
|
||||
: 'Not synced yet'}
|
||||
? t('calendar.subscription.last_refreshed', 'Last updated: {time}', {
|
||||
time: formatDistanceToNow(sub.lastSyncAt, { addSuffix: true, locale }),
|
||||
})
|
||||
: t('calendar.subscription.not_synced', 'Not synced yet')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -229,6 +265,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
disabled={busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.subscription.refresh', 'Refresh now')}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.textMuted} />
|
||||
@@ -240,6 +278,8 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) {
|
||||
onPress={() => { void removeSubscription(sub.id); }}
|
||||
hitSlop={6}
|
||||
style={styles.subBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.subscription.unsubscribe', 'Unsubscribe')}
|
||||
>
|
||||
<Trash2 size={16} color={c.error} />
|
||||
</Pressable>
|
||||
|
||||
@@ -93,7 +93,12 @@ export function ParticipantInput({ attendees, onAdd, onRemove }: ParticipantInpu
|
||||
<Text style={styles.chipText} numberOfLines={1}>
|
||||
{a.name || a.email}
|
||||
</Text>
|
||||
<Pressable onPress={() => onRemove(a.email)} hitSlop={6}>
|
||||
<Pressable
|
||||
onPress={() => onRemove(a.email)}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.participants.remove', 'Remove')}
|
||||
>
|
||||
<X size={12} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -7,9 +7,10 @@ import type { RecurrenceRule } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Button } from '..';
|
||||
import { useCalendarLocale } from '../../lib/calendar-locale';
|
||||
import {
|
||||
EDITOR_FREQUENCIES,
|
||||
UNIT_LABELS,
|
||||
UNIT_LABEL_KEYS,
|
||||
WEEKDAYS,
|
||||
buildRecurrenceSummary,
|
||||
buildRuleFromEditorValue,
|
||||
@@ -80,11 +81,13 @@ function NumberInput({
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
accessibilityLabel,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
accessibilityLabel?: string;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -102,6 +105,7 @@ function NumberInput({
|
||||
}}
|
||||
keyboardType="number-pad"
|
||||
style={styles.numberInput}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +113,7 @@ function NumberInput({
|
||||
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const { locale, t } = useCalendarLocale();
|
||||
|
||||
const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
|
||||
editorValueFromRule(rule, eventStart),
|
||||
@@ -130,22 +135,23 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
};
|
||||
|
||||
const builtRule = buildRuleFromEditorValue(value, eventStart);
|
||||
const summary = buildRecurrenceSummary(builtRule);
|
||||
const summary = buildRecurrenceSummary(builtRule, t, locale);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Every</Text>
|
||||
<Text style={styles.rowLabel}>{t('calendar.recurrence.editor_every', 'Every')}</Text>
|
||||
<NumberInput
|
||||
value={value.interval}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(interval) => update({ interval })}
|
||||
accessibilityLabel={t('calendar.recurrence.editor_every', 'Every')}
|
||||
/>
|
||||
<InlineSelect<EditorFrequency>
|
||||
flex
|
||||
value={value.frequency}
|
||||
options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: UNIT_LABELS[f] }))}
|
||||
options={EDITOR_FREQUENCIES.map((f) => ({ value: f, label: t(UNIT_LABEL_KEYS[f][0], UNIT_LABEL_KEYS[f][1]) }))}
|
||||
onChange={(frequency) => update({ frequency })}
|
||||
/>
|
||||
</View>
|
||||
@@ -159,9 +165,12 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
key={day}
|
||||
onPress={() => toggleWeekDay(day)}
|
||||
style={[styles.weekdayChip, active && styles.weekdayChipActive]}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={weekdayName(day, 'long', locale)}
|
||||
accessibilityState={{ checked: active }}
|
||||
>
|
||||
<Text style={[styles.weekdayChipText, active && styles.weekdayChipTextActive]}>
|
||||
{weekdayName(day, 'short')}
|
||||
{weekdayName(day, 'short', locale)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -171,13 +180,13 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
|
||||
{value.frequency === 'yearly' && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>In</Text>
|
||||
<Text style={styles.rowLabel}>{capitalize(t('calendar.recurrence.editor_in', 'in'))}</Text>
|
||||
<InlineSelect<number>
|
||||
flex
|
||||
value={value.month}
|
||||
options={Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ({
|
||||
value: m,
|
||||
label: capitalize(monthName(m)),
|
||||
label: capitalize(monthName(m, locale)),
|
||||
}))}
|
||||
onChange={(month) => update({ month })}
|
||||
/>
|
||||
@@ -189,8 +198,8 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
<InlineSelect<MonthlyMode>
|
||||
value={value.monthlyMode}
|
||||
options={[
|
||||
{ value: 'day', label: 'On day' },
|
||||
{ value: 'nth', label: 'On the' },
|
||||
{ value: 'day', label: capitalize(t('calendar.recurrence.editor_on_day', 'on day')) },
|
||||
{ value: 'nth', label: capitalize(t('calendar.recurrence.editor_on_the', 'on the')) },
|
||||
]}
|
||||
onChange={(monthlyMode) => update({ monthlyMode })}
|
||||
/>
|
||||
@@ -200,19 +209,20 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
min={1}
|
||||
max={31}
|
||||
onChange={(monthDay) => update({ monthDay })}
|
||||
accessibilityLabel={t('calendar.recurrence.editor_on_day', 'on day')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<InlineSelect<number>
|
||||
flex
|
||||
value={value.nth}
|
||||
options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n)) }))}
|
||||
options={[1, 2, 3, 4, -1].map((n) => ({ value: n, label: capitalize(nthLabel(n, t)) }))}
|
||||
onChange={(nth) => update({ nth })}
|
||||
/>
|
||||
<InlineSelect<string>
|
||||
flex
|
||||
value={value.nthDay}
|
||||
options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d)) }))}
|
||||
options={WEEKDAYS.map((d) => ({ value: d, label: capitalize(weekdayName(d, 'long', locale)) }))}
|
||||
onChange={(nthDay) => update({ nthDay })}
|
||||
/>
|
||||
</>
|
||||
@@ -221,21 +231,21 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
)}
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Ends</Text>
|
||||
<Text style={styles.rowLabel}>{t('calendar.recurrence.editor_ends', 'Ends')}</Text>
|
||||
<InlineSelect<EndsMode>
|
||||
flex={value.endsMode === 'never'}
|
||||
value={value.endsMode}
|
||||
options={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: 'on', label: 'On date' },
|
||||
{ value: 'after', label: 'After' },
|
||||
{ value: 'never', label: t('calendar.recurrence.editor_never', 'Never') },
|
||||
{ value: 'on', label: t('calendar.recurrence.editor_ends_on', 'On') },
|
||||
{ value: 'after', label: t('calendar.recurrence.editor_ends_after', 'After') },
|
||||
]}
|
||||
onChange={(endsMode) => update({ endsMode })}
|
||||
/>
|
||||
{value.endsMode === 'on' && (
|
||||
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
|
||||
<Text style={styles.selectText}>
|
||||
{format(parseISO(value.untilDate), 'MMM d, yyyy')}
|
||||
{format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
@@ -246,8 +256,11 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(count) => update({ count })}
|
||||
accessibilityLabel={t('calendar.recurrence.editor_ends_after', 'After')}
|
||||
/>
|
||||
<Text style={styles.muted}>occurrences</Text>
|
||||
<Text style={styles.muted}>
|
||||
{t('calendar.recurrence.editor_occurrences_count', '{count, plural, one {occurrence} other {occurrences}}', { count: value.count })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -258,10 +271,10 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
|
||||
</Text>
|
||||
<View style={styles.footerButtons}>
|
||||
<Button variant="outline" size="sm" onPress={onCancel}>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onPress={() => onSave(builtRule)}>
|
||||
Done
|
||||
{t('common.done', 'Done')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -241,7 +241,13 @@ export function TasksSheet({
|
||||
<View style={styles.header}>
|
||||
<ListChecks size={20} color={c.text} />
|
||||
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
|
||||
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={20} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -262,6 +268,8 @@ export function TasksSheet({
|
||||
onPress={() => { void handleSave(); }}
|
||||
disabled={!editor.title.trim() || !editor.calendarId || saving}
|
||||
style={[styles.addBtn, (!editor.title.trim() || saving) && styles.addBtnDisabled]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={editor.id ? t('calendar.tasks.save', 'Save') : t('calendar.tasks.create', 'New Task')}
|
||||
>
|
||||
{editor.id ? <Check size={18} color={c.primaryForeground} /> : <Plus size={18} color={c.primaryForeground} />}
|
||||
</Pressable>
|
||||
@@ -290,6 +298,8 @@ export function TasksSheet({
|
||||
<Pressable
|
||||
hitSlop={6}
|
||||
onPress={() => setEditor((e) => ({ ...e, due: null, withTime: false }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.tasks.clear_due_date', 'Clear due date')}
|
||||
>
|
||||
<X size={12} color={c.textMuted} />
|
||||
</Pressable>
|
||||
@@ -383,6 +393,9 @@ export function TasksSheet({
|
||||
onPress={() => { void onToggle(task.id); }}
|
||||
style={[styles.checkbox, completed && styles.checkboxChecked]}
|
||||
hitSlop={8}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={task.title || t('calendar.tasks.no_title', '(No title)')}
|
||||
accessibilityState={{ checked: completed }}
|
||||
>
|
||||
{completed && <Check size={14} color={c.primaryForeground} />}
|
||||
</Pressable>
|
||||
@@ -407,7 +420,13 @@ export function TasksSheet({
|
||||
<Text style={[styles.taskDue, overdue && styles.taskDueOverdue]}>{due}</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
|
||||
<Pressable
|
||||
onPress={() => { void onDelete(task.id); }}
|
||||
hitSlop={8}
|
||||
style={styles.taskDelete}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('calendar.tasks.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user