Files
webmail/components/settings/accessibility-settings.tsx
T
Linus Rath e2236ea476 Merge branch 'feat/do-uxs-general' into feat/do-ux-settings-fixes
# Conflicts:
#	components/settings/__tests__/settings-search-anchors.test.tsx
#	components/settings/accessibility-settings.tsx
#	lib/settings-search.ts
2026-11-04 08:34:25 +01:00

533 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { SettingsSection, SettingItem, RadioGroup, Select, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { ChevronRight, Keyboard } from '@/components/icons';
import { useAuthStore } from '@/stores/auth-store';
import { openKeyboardShortcuts } from '@/lib/app-commands';
import { voicesForLocale } from '@/lib/read-aloud';
import { useSpeechVoices } from '@/hooks/use-read-aloud';
import {
ACCESSIBILITY_KEYS,
FONT_SIZES,
MAIL_MIN_FONT_SIZES,
MAIL_TEXT_SCALES,
READ_ALOUD_RATES,
TOAST_DURATIONS,
toastDurationAtLeast,
type FontSize,
} from '@/lib/accessibility-settings';
import {
ACCESSIBILITY_PRESET_IDS,
PRESET_SETTING_LABELS,
presetChanges,
type AccessibilityPresetId,
type PresetChange,
} from '@/lib/accessibility-presets';
import { AccessibilityPresetPreview } from './accessibility-preset-preview';
import { FONT_SIZES as COMPOSE_FONT_SIZES } from '@/components/email/font-size';
type SettingKey = Parameters<ReturnType<typeof useSettingsStore.getState>['updateSetting']>[0];
/** What Reset gives back: the settings only this page has. */
const RESET_KEYS: SettingKey[] = [...ACCESSIBILITY_KEYS, 'fontSize', 'plainTextFont', 'sendConfirmation'];
/** Opens another settings page (and section) the way deep links do. */
function goToSettings(tab: string, section?: string) {
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: section ? { tab, section } : tab }));
}
function LinkButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Button variant="outline" size="sm" onClick={onClick}>
{label}
<ChevronRight className="w-4 h-4 ms-1" />
</Button>
);
}
/**
* Settings > Accessibility: presets on top, then the options by topic. Only
* settings about access live here; everyday preferences that help too
* (density, conversation view, undo send, sounds…) stay on their own page,
* and a link row in the matching section leads there. One setting, one place.
*/
export function AccessibilitySettings() {
const t = useTranslations('settings.accessibility');
const tRoot = useTranslations();
const tLinks = useTranslations('uxs_structure.a11y_links');
const settings = useSettingsStore();
const { updateSetting, resetSettings } = settings;
const { isSettingLocked, isSettingHidden, getRestriction, isFeatureEnabled } = usePolicyStore();
const calendarShown = (useAuthStore((s) => s.client)?.supportsCalendars() ?? false) && isFeatureEnabled('calendarEnabled');
const [applied, setApplied] = useState<{ id: AccessibilityPresetId; changes: PresetChange[] } | null>(null);
const [resetDone, setResetDone] = useState(false);
const locale = useLocale();
const speechVoices = useSpeechVoices();
const toastMin = getRestriction('toastDuration')?.min;
const allowsValue = (key: string, value: unknown) =>
key !== 'toastDuration' || typeof toastMin !== 'number' || toastDurationAtLeast(value as number, toastMin);
const set = (key: string, value: unknown) => updateSetting(key as SettingKey, value as never);
const applyPreset = (id: AccessibilityPresetId) => {
const changes = presetChanges(id, useSettingsStore.getState() as unknown as Record<string, unknown>, isSettingLocked, allowsValue);
for (const change of changes) set(change.key, change.to);
setApplied({ id, changes });
setResetDone(false);
};
const undoPreset = () => {
if (!applied) return;
for (const change of applied.changes) set(change.key, change.from);
setApplied(null);
};
// Forgets the user's own values, so the organisation's defaults (or
// Bulwark's) apply again. Shared everyday settings shown here too stay.
const resetAll = () => {
resetSettings(RESET_KEYS.filter((key) => !isSettingLocked(key)));
setApplied(null);
setResetDone(true);
};
const systemToggle = (key: 'reduceMotion' | 'reduceTransparency' | 'pauseAnimatedImages' | 'largeTargets') => (
<RadioGroup
value={settings[key]}
onChange={(value) => set(key, value)}
options={[
{ value: 'system', label: t('system') },
{ value: 'on', label: t('on') },
{ value: 'off', label: t('off') },
]}
/>
);
// Voices for the interface language, plus the one already chosen even if
// this browser doesn't have it (another device may).
const localeVoices = voicesForLocale(speechVoices, locale);
const readAloudVoiceOptions = [
{ value: '', label: t('read_aloud_voice.default') },
...localeVoices.map((voice) => ({ value: voice.voiceURI, label: voice.name })),
...(settings.readAloudVoice && !localeVoices.some((voice) => voice.voiceURI === settings.readAloudVoice)
? [{ value: settings.readAloudVoice, label: speechVoices.find((voice) => voice.voiceURI === settings.readAloudVoice)?.name ?? settings.readAloudVoice }]
: []),
];
const toastOptions = TOAST_DURATIONS
.filter((value) => allowsValue('toastDuration', value))
.map((value) => ({
value: String(value),
label: value === 0 ? t('toast_duration.until_closed') : t('toast_duration.seconds', { seconds: value }),
}));
return (
<div className="space-y-8">
<SettingsSection id="presets" title={t('presets.title')} description={t('presets.description')}>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2" role="group" aria-label={t('presets.title')}>
{ACCESSIBILITY_PRESET_IDS.map((id) => (
<button
key={id}
type="button"
onClick={() => applyPreset(id)}
className="flex items-start gap-3 text-start rounded-md border border-border px-3 py-2.5 hover:bg-muted transition-colors"
>
<AccessibilityPresetPreview id={id} />
<span className="min-w-0 wrap-anywhere">
<span className="block text-sm font-medium text-foreground">{t(`presets.${id}.label`)}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{t(`presets.${id}.description`)}</span>
</span>
</button>
))}
</div>
<div role="status" aria-live="polite">
{applied && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2.5 space-y-2" data-testid="preset-summary">
<p className="text-sm font-medium text-foreground">
{applied.changes.length === 0
? t('presets.nothing_changed', { name: t(`presets.${applied.id}.label`) })
: t('presets.applied', { name: t(`presets.${applied.id}.label`), count: applied.changes.length })}
</p>
{applied.changes.length > 0 && (
<ul className="text-xs text-muted-foreground list-disc ps-5 space-y-0.5">
{applied.changes.map((change) => (
<li key={change.key}>{PRESET_SETTING_LABELS[change.key] ? tRoot(PRESET_SETTING_LABELS[change.key]) : change.key}</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-2">
{applied.changes.length > 0 && (
<Button variant="outline" size="sm" onClick={undoPreset}>{t('presets.undo')}</Button>
)}
{!settings.showAccessibilityButton && (
<Button variant="outline" size="sm" onClick={() => set('showAccessibilityButton', true)}>
{t('presets.show_button')}
</Button>
)}
</div>
</div>
)}
</div>
<SettingItem label={t('quick_button.label')} description={t('quick_button.description')}>
<ToggleSwitch checked={settings.showAccessibilityButton} onChange={(checked) => set('showAccessibilityButton', checked)} />
</SettingItem>
</SettingsSection>
<SettingsSection id="text" title={t('sections.text')}>
<SettingItem label={t('text_size.label')} description={t('text_size.description')}>
<Select
value={settings.fontSize}
onChange={(value) => set('fontSize', value as FontSize)}
options={FONT_SIZES.map((size) => ({ value: size, label: t(`text_size.${size}`) }))}
/>
</SettingItem>
<SettingItem label={t('font.label')} description={t('font.description')}>
<Select
value={settings.appFont}
onChange={(value) => set('appFont', value)}
options={[
{ value: 'default', label: t('font.default') },
{ value: 'atkinson', label: 'Atkinson Hyperlegible' },
{ value: 'lexend', label: 'Lexend' },
{ value: 'opendyslexic', label: 'OpenDyslexic' },
]}
/>
</SettingItem>
<SettingItem label={t('plain_text_font.label')} description={t('plain_text_font.description')}>
<Select
value={settings.plainTextFont}
onChange={(value) => set('plainTextFont', value)}
options={[
{ value: 'sans', label: t('plain_text_font.sans') },
{ value: 'mono', label: t('plain_text_font.mono') },
]}
/>
</SettingItem>
<SettingItem label={t('line_spacing.label')} description={t('line_spacing.description')}>
<RadioGroup
value={settings.lineSpacing}
onChange={(value) => set('lineSpacing', value)}
options={[
{ value: 'normal', label: t('spacing.normal') },
{ value: 'relaxed', label: t('spacing.relaxed') },
{ value: 'loose', label: t('spacing.loose') },
]}
/>
</SettingItem>
<SettingItem label={t('letter_spacing.label')} description={t('letter_spacing.description')}>
<RadioGroup
value={settings.letterSpacing}
onChange={(value) => set('letterSpacing', value)}
options={[
{ value: 'normal', label: t('spacing.normal') },
{ value: 'wide', label: t('spacing.wide') },
{ value: 'wider', label: t('spacing.wider') },
]}
/>
</SettingItem>
<SettingItem label={t('word_spacing.label')} description={t('word_spacing.description')}>
<RadioGroup
value={settings.wordSpacing}
onChange={(value) => set('wordSpacing', value)}
options={[
{ value: 'normal', label: t('spacing.normal') },
{ value: 'wide', label: t('spacing.wide') },
]}
/>
</SettingItem>
<SettingItem label={t('paragraph_spacing.label')} description={t('paragraph_spacing.description')}>
<RadioGroup
value={settings.paragraphSpacing}
onChange={(value) => set('paragraphSpacing', value)}
options={[
{ value: 'normal', label: t('spacing.normal') },
{ value: 'wide', label: t('spacing.wide') },
]}
/>
</SettingItem>
<SettingItem label={t('reading_width.label')} description={t('reading_width.description')}>
<RadioGroup
value={settings.readingWidth}
onChange={(value) => set('readingWidth', value)}
options={[
{ value: 'full', label: t('reading_width.full') },
{ value: 'comfortable', label: t('reading_width.comfortable') },
]}
/>
</SettingItem>
<SettingItem label={t('reading_mask.label')} description={t('reading_mask.description')}>
<ToggleSwitch checked={settings.readingMask} onChange={(checked) => set('readingMask', checked)} />
</SettingItem>
<SettingItem label={tLinks('text.label')} description={tLinks('text.description')}>
<LinkButton label={tRoot('settings.tabs.appearance')} onClick={() => goToSettings('appearance', 'text')} />
</SettingItem>
</SettingsSection>
<SettingsSection id="contrast" title={t('sections.contrast')}>
<SettingItem label={t('contrast.label')} description={t('contrast.description')}>
<RadioGroup
value={settings.contrast}
onChange={(value) => set('contrast', value)}
options={[
{ value: 'system', label: t('system') },
{ value: 'more', label: t('contrast.more') },
{ value: 'normal', label: t('contrast.normal') },
]}
/>
</SettingItem>
<SettingItem label={t('transparency.label')} description={t('transparency.description')}>
{systemToggle('reduceTransparency')}
</SettingItem>
<SettingItem label={t('underline_links.label')} description={t('underline_links.description')}>
<ToggleSwitch checked={settings.underlineLinks} onChange={(checked) => set('underlineLinks', checked)} />
</SettingItem>
<SettingItem label={t('color_names.label')} description={t('color_names.description')}>
<ToggleSwitch checked={settings.showColorNames} onChange={(checked) => set('showColorNames', checked)} />
</SettingItem>
<SettingItem label={t('cvd_palette.label')} description={t('cvd_palette.description')}>
<ToggleSwitch checked={settings.colorBlindPalette} onChange={(checked) => set('colorBlindPalette', checked)} />
</SettingItem>
<SettingItem label={t('theme_link.label')} description={t('theme_link.description')}>
<LinkButton label={t('theme_link.button')} onClick={() => goToSettings('appearance', 'theme')} />
</SettingItem>
<SettingItem label={tLinks('light_mode.label')} description={tLinks('light_mode.description')}>
<LinkButton label={tRoot('settings.tabs.reading')} onClick={() => goToSettings('reading', 'display')} />
</SettingItem>
</SettingsSection>
<SettingsSection id="motion" title={t('sections.motion')}>
<SettingItem label={t('reduce_motion.label')} description={t('reduce_motion.description')}>
{systemToggle('reduceMotion')}
</SettingItem>
<SettingItem label={t('pause_images.label')} description={t('pause_images.description')}>
{systemToggle('pauseAnimatedImages')}
</SettingItem>
</SettingsSection>
<SettingsSection id="keyboard" title={t('sections.keyboard')}>
{!isSettingHidden('singleKeyShortcuts') && (
<SettingItem label={t('single_key_shortcuts.label')} description={t('single_key_shortcuts.description')} locked={isSettingLocked('singleKeyShortcuts')}>
<ToggleSwitch checked={settings.singleKeyShortcuts} onChange={(checked) => set('singleKeyShortcuts', checked)} />
</SettingItem>
)}
<SettingItem label={t('shortcut_list.label')} description={t('shortcut_list.description')}>
<Button variant="outline" size="sm" onClick={openKeyboardShortcuts}>
<Keyboard className="w-4 h-4 me-1" />
{t('shortcut_list.button')}
</Button>
</SettingItem>
<SettingItem label={t('focus_ring.label')} description={t('focus_ring.description')}>
<RadioGroup
value={settings.focusRing}
onChange={(value) => set('focusRing', value)}
options={[
{ value: 'normal', label: t('focus_ring.normal') },
{ value: 'thick', label: t('focus_ring.thick') },
{ value: 'extra', label: t('focus_ring.extra') },
]}
/>
</SettingItem>
<SettingItem label={t('focus_always.label')} description={t('focus_always.description')}>
<ToggleSwitch checked={settings.focusAlwaysVisible} onChange={(checked) => set('focusAlwaysVisible', checked)} />
</SettingItem>
</SettingsSection>
<SettingsSection id="pointer" title={t('sections.pointer')}>
<SettingItem label={t('large_targets.label')} description={t('large_targets.description')}>
{systemToggle('largeTargets')}
</SettingItem>
<SettingItem label={t('large_cursor.label')} description={t('large_cursor.description')}>
<ToggleSwitch checked={settings.largeCursor} onChange={(checked) => set('largeCursor', checked)} />
</SettingItem>
<SettingItem label={t('open_on_hover.label')} description={t('open_on_hover.description')}>
<ToggleSwitch checked={settings.openOnHover} onChange={(checked) => set('openOnHover', checked)} />
</SettingItem>
<SettingItem label={t('row_action_button.label')} description={t('row_action_button.description')}>
<ToggleSwitch checked={settings.showRowActionButton} onChange={(checked) => set('showRowActionButton', checked)} />
</SettingItem>
<SettingItem label={t('native_scrollbars.label')} description={t('native_scrollbars.description')}>
<ToggleSwitch checked={settings.nativeScrollbars} onChange={(checked) => set('nativeScrollbars', checked)} />
</SettingItem>
<SettingItem label={t('row_actions_link.label')} description={t('row_actions_link.description')}>
<LinkButton label={t('row_actions_link.button')} onClick={() => goToSettings('message_list', 'actions')} />
</SettingItem>
{calendarShown && (
<SettingItem label={tLinks('calendar.label')} description={tLinks('calendar.description')}>
<LinkButton label={tRoot('settings.tabs.calendar')} onClick={() => goToSettings('calendar')} />
</SettingItem>
)}
</SettingsSection>
<SettingsSection id="mail" title={t('sections.mail')}>
<SettingItem label={t('mail_view.label')} description={t('mail_view.description')}>
<RadioGroup
value={settings.mailViewMode}
onChange={(value) => set('mailViewMode', value)}
options={[
{ value: 'html', label: t('mail_view.html') },
{ value: 'plain', label: t('mail_view.plain') },
]}
/>
</SettingItem>
<SettingItem label={t('mail_text_size.label')} description={t('mail_text_size.description')}>
<Select
value={String(settings.mailTextScale)}
onChange={(value) => set('mailTextScale', Number(value))}
options={MAIL_TEXT_SCALES.map((scale) => ({
value: String(scale),
label: scale === 0 ? t('mail_text_size.follow') : `${Math.round(scale * 100)} %`,
}))}
/>
</SettingItem>
<SettingItem label={t('mail_min_font_size.label')} description={t('mail_min_font_size.description')}>
<Select
value={String(settings.mailMinFontSize)}
onChange={(value) => set('mailMinFontSize', Number(value))}
options={MAIL_MIN_FONT_SIZES.map((size) => ({
value: String(size),
label: size === 0 ? t('mail_min_font_size.none') : t('mail_min_font_size.px', { size }),
}))}
/>
</SettingItem>
<SettingItem label={t('mail_override.label')} description={t('mail_override.description')}>
<Select
value={settings.mailOverrideStyles}
onChange={(value) => set('mailOverrideStyles', value)}
options={[
{ value: 'never', label: t('mail_override.never') },
{ value: 'high-contrast', label: t('mail_override.high_contrast') },
{ value: 'always', label: t('mail_override.always') },
]}
/>
</SettingItem>
<SettingItem label={t('mail_fit_width.label')} description={t('mail_fit_width.description')}>
<ToggleSwitch checked={settings.mailFitWidth} onChange={(checked) => set('mailFitWidth', checked)} />
</SettingItem>
<SettingItem label={t('read_aloud_rate.label')} description={t('read_aloud_rate.description')}>
<RadioGroup
value={String(settings.readAloudRate)}
onChange={(value) => set('readAloudRate', Number(value))}
options={READ_ALOUD_RATES.map((rate) => ({ value: String(rate), label: `${rate}×` }))}
/>
</SettingItem>
<SettingItem label={t('read_aloud_voice.label')} description={t('read_aloud_voice.description')}>
<Select
value={settings.readAloudVoice}
onChange={(value) => set('readAloudVoice', value)}
options={readAloudVoiceOptions}
/>
</SettingItem>
<SettingItem label={t('compose_font.label')} description={t('compose_font.description')}>
<Select
value={settings.composeFontFamily}
onChange={(value) => set('composeFontFamily', value)}
options={[
{ value: '', label: t('compose_font.default') },
{ value: 'sans-serif', label: t('compose_font.sans') },
{ value: 'serif', label: t('compose_font.serif') },
{ value: 'monospace', label: t('compose_font.mono') },
]}
/>
</SettingItem>
<SettingItem label={t('compose_size.label')} description={t('compose_size.description')}>
<Select
value={settings.composeFontSize}
onChange={(value) => set('composeFontSize', value)}
options={[
{ value: '', label: t('compose_size.default') },
...COMPOSE_FONT_SIZES.map((size) => ({ value: size, label: size })),
]}
/>
</SettingItem>
<SettingItem label={tLinks('list.label')} description={tLinks('list.description')}>
<LinkButton label={tRoot('settings.tabs.message_list')} onClick={() => goToSettings('message_list', 'display')} />
</SettingItem>
<SettingItem label={tLinks('reading.label')} description={tLinks('reading.description')}>
<LinkButton label={tRoot('settings.tabs.reading')} onClick={() => goToSettings('reading', 'opening')} />
</SettingItem>
</SettingsSection>
<SettingsSection id="timing" title={t('sections.timing')}>
<SettingItem label={t('toast_duration.label')} description={t('toast_duration.description')} locked={isSettingLocked('toastDuration')}>
<Select
value={String(settings.toastDuration)}
onChange={(value) => set('toastDuration', Number(value))}
options={toastOptions}
/>
</SettingItem>
{!isSettingHidden('sendConfirmation') && (
<SettingItem label={t('confirm_send.label')} description={t('confirm_send.description')} locked={isSettingLocked('sendConfirmation')}>
<ToggleSwitch checked={settings.sendConfirmation} onChange={(checked) => set('sendConfirmation', checked)} />
</SettingItem>
)}
<SettingItem label={t('confirm_delete.label')} description={t('confirm_delete.description')}>
<ToggleSwitch checked={settings.confirmDelete} onChange={(checked) => set('confirmDelete', checked)} />
</SettingItem>
<SettingItem label={t('confirm_archive.label')} description={t('confirm_archive.description')}>
<ToggleSwitch checked={settings.confirmArchive} onChange={(checked) => set('confirmArchive', checked)} />
</SettingItem>
<SettingItem label={t('send_warnings_link.label')} description={tLinks('send_warnings_description')}>
<LinkButton label={t('send_warnings_link.button')} onClick={() => goToSettings('composing', 'sending')} />
</SettingItem>
<SettingItem label={tLinks('bulk_delete.label')} description={tLinks('bulk_delete.description')}>
<LinkButton label={tRoot('settings.tabs.message_list')} onClick={() => goToSettings('message_list', 'delete')} />
</SettingItem>
</SettingsSection>
<SettingsSection id="alerts" title={t('sections.alerts')}>
<SettingItem label={t('visual_alerts.label')} description={t('visual_alerts.description')}>
<ToggleSwitch checked={settings.visualAlerts} onChange={(checked) => set('visualAlerts', checked)} />
</SettingItem>
<SettingItem label={t('unread_counts.label')} description={t('unread_counts.description')}>
<RadioGroup
value={settings.unreadCountStyle}
onChange={(value) => set('unreadCountStyle', value)}
options={[
{ value: 'count', label: t('unread_counts.count') },
{ value: 'dot', label: t('unread_counts.dot') },
{ value: 'hidden', label: t('unread_counts.hidden') },
]}
/>
</SettingItem>
<SettingItem label={t('do_not_disturb.label')} description={t('do_not_disturb.description')}>
<ToggleSwitch checked={settings.doNotDisturb} onChange={(checked) => set('doNotDisturb', checked)} />
</SettingItem>
<SettingItem label={tLinks('sounds.label')} description={tLinks('sounds.description')}>
<LinkButton label={tRoot('settings.tabs.notifications')} onClick={() => goToSettings('notifications', 'sound')} />
</SettingItem>
</SettingsSection>
<SettingsSection id="screen-readers" title={t('sections.screen_readers')}>
<SettingItem label={t('announce_new_mail.label')} description={t('announce_new_mail.description')}>
<ToggleSwitch checked={settings.announceNewMail} onChange={(checked) => set('announceNewMail', checked)} />
</SettingItem>
<SettingItem label={t('announce_preview.label')} description={t('announce_preview.description')}>
<ToggleSwitch checked={settings.announceRowPreview} onChange={(checked) => set('announceRowPreview', checked)} />
</SettingItem>
<SettingItem label={t('simple_mode.label')} description={t('simple_mode.description')}>
<ToggleSwitch checked={settings.simpleMode} onChange={(checked) => set('simpleMode', checked)} />
</SettingItem>
{settings.simpleMode && (
<SettingItem nested label={t('simple_mode_paged.label')} description={t('simple_mode_paged.description')}>
<ToggleSwitch checked={settings.simpleModePagedList} onChange={(checked) => set('simpleModePagedList', checked)} />
</SettingItem>
)}
</SettingsSection>
<SettingsSection id="reset" title={t('reset.title')}>
<SettingItem label={t('reset.label')} description={t('reset.description')}>
<div className="flex flex-col items-end gap-1">
<Button variant="outline" size="sm" onClick={resetAll}>{t('reset.button')}</Button>
<p role="status" className="text-xs text-muted-foreground">{resetDone ? t('reset.done') : ''}</p>
</div>
</SettingItem>
</SettingsSection>
</div>
);
}