diff --git a/components/icons/index.ts b/components/icons/index.ts index 422f660ad..6b1972953 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -33,6 +33,7 @@ export { IconBaseline as Baseline, IconBell as Bell, IconBellOff as BellOff, + IconBellRinging as BellRinging, IconBold as Bold, IconBook2 as Book, IconBook as BookOpen, @@ -184,6 +185,7 @@ export { IconPlayerPlay as Play, IconPlayerPlay as PlayCircle, IconPlus as Plus, + IconPointer as Pointer, IconPower as Power, IconPower as PowerOff, IconPresentation as Presentation, @@ -249,6 +251,7 @@ export { IconUsers as UsersRound, IconVideo as Video, IconVolume2 as Volume2, + IconVolumeOff as VolumeOff, IconX as X, IconCircleX as XCircle, IconBolt as Zap, diff --git a/components/settings/__tests__/accessibility-settings.test.tsx b/components/settings/__tests__/accessibility-settings.test.tsx index d4907cbf3..d8a73d641 100644 --- a/components/settings/__tests__/accessibility-settings.test.tsx +++ b/components/settings/__tests__/accessibility-settings.test.tsx @@ -4,7 +4,7 @@ import { AccessibilitySettings } from '../accessibility-settings'; import { useSettingsStore } from '@/stores/settings-store'; import { usePolicyStore } from '@/stores/policy-store'; import { DEFAULT_POLICY } from '@/lib/admin/types'; -import { presetChanges } from '@/lib/accessibility-presets'; +import { ACCESSIBILITY_PRESET_IDS, presetChanges } from '@/lib/accessibility-presets'; // Labels are their keys; plural/ICU arguments are appended so tests can see them. vi.mock('next-intl', () => ({ @@ -50,6 +50,18 @@ describe('accessibility presets', () => { expect(settings().contrast).toBe('system'); }); + it('draws each preset on its card without adding to the name screen readers hear', () => { + render(); + for (const id of ACCESSIBILITY_PRESET_IDS) { + // jsdom joins the two spans without a space, so compare without any. + const expected = `settings.accessibility.presets.${id}.labelsettings.accessibility.presets.${id}.description`; + const button = screen.getByRole('button', { name: (name) => name.replace(/\s/g, '') === expected }); + const preview = within(button).getByTestId(`preset-preview-${id}`); + expect(preview.getAttribute('aria-hidden')).toBe('true'); + expect(preview.querySelector('span, svg')).toBeTruthy(); + } + }); + it('combines presets: a second one keeps what the first set', () => { render(); fireEvent.click(presetButton('dyslexia')); diff --git a/components/settings/accessibility-preset-preview.tsx b/components/settings/accessibility-preset-preview.tsx new file mode 100644 index 000000000..2e63f2964 --- /dev/null +++ b/components/settings/accessibility-preset-preview.tsx @@ -0,0 +1,195 @@ +"use client"; + +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { BellRinging, Ellipsis, Image as ImageIcon, Pause, Pointer, Volume2, VolumeOff, X } from '@/components/icons'; +import type { AccessibilityPresetId } from '@/lib/accessibility-presets'; + +/* + * A small drawing of the mail view on each preset card that shows what the + * preset changes before it is applied. Sizes are in px so the drawing keeps + * its proportions whatever text size the user has set, and it is hidden + * from screen readers: the card's description says the same in words. + */ + +function Bar({ width, strong, className }: { width: number | string; strong?: boolean; className?: string }) { + return ( + + ); +} + +function Avatar({ size = 8 }: { size?: number }) { + return ; +} + +function Frame({ children, className }: { children: ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +/** A message row: avatar, sender, and optionally the preview line. */ +function Row({ preview = true, avatar = true, className }: { preview?: boolean; avatar?: boolean; className?: string }) { + return ( + + {avatar && } + + + {preview && } + + + ); +} + +function ScreenReaderPreview() { + // One column, no reading pane, and the focused row read out. + return ( + + + + + + + + ); +} + +function LowVisionPreview() { + // Big, high-contrast text, a thick focus outline, an underlined link, a big pointer. + return ( + + Aa + + + + + + + + + + ); +} + +function DyslexiaPreview() { + // A clearer font, more room between letters, words and lines, a narrower column. + const lines = [[14, 9, 18, 10], [8, 16, 12], [12, 10, 15, 6], [16, 11]]; + return ( + + + + Abc + + {lines.map((words, i) => ( + + {words.map((width, j) => )} + + ))} + + + ); +} + +function FocusPreview() { + // A plain list: no pictures, no preview lines, a dot instead of a count. + return ( + + + + + + + + + + + + + + + + + ); +} + +function MotorPreview() { + // Tall rows, an actions button on each, a thick outline on the focused one. + return ( + + {[0, 1, 2].map((i) => ( + + + + + + + + ))} + + ); +} + +function MotionPreview() { + // A paused animated image on a solid panel, with no movement anywhere. + return ( + + + + + + + + + + + ); +} + +function HearingPreview() { + // A notification that stays until closed and a flash at the edge instead of a sound. + return ( + + + + + + + + + + + ); +} + +const PREVIEWS: Record ReactNode> = { + 'screen-reader': ScreenReaderPreview, + 'low-vision': LowVisionPreview, + dyslexia: DyslexiaPreview, + focus: FocusPreview, + motor: MotorPreview, + motion: MotionPreview, + hearing: HearingPreview, +}; + +export function AccessibilityPresetPreview({ id }: { id: AccessibilityPresetId }) { + const Preview = PREVIEWS[id]; + return ( + + ); +} diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx index 689af64c9..371f96ac8 100644 --- a/components/settings/accessibility-settings.tsx +++ b/components/settings/accessibility-settings.tsx @@ -31,6 +31,7 @@ import { 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'; import type { Density, SendDelaySeconds } from '@/stores/settings-store'; @@ -139,10 +140,13 @@ export function AccessibilitySettings() { key={id} type="button" onClick={() => applyPreset(id)} - className="text-start rounded-md border border-border px-3 py-2.5 hover:bg-muted transition-colors" + className="flex items-start gap-3 text-start rounded-md border border-border px-3 py-2.5 hover:bg-muted transition-colors" > - {t(`presets.${id}.label`)} - {t(`presets.${id}.description`)} + + + {t(`presets.${id}.label`)} + {t(`presets.${id}.description`)} + ))}