diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index bc381b66c..eb4793724 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -13,6 +13,7 @@ import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; import { PluginPageNavigator } from "@/components/plugins/plugin-page-navigator"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; +import { AccessibilityQuickPanelHost } from "@/components/accessibility/accessibility-quick-panel"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; @@ -77,6 +78,7 @@ export default async function LocaleLayout({ + diff --git a/components/accessibility/__tests__/accessibility-quick-panel.test.tsx b/components/accessibility/__tests__/accessibility-quick-panel.test.tsx new file mode 100644 index 000000000..f9b243cf6 --- /dev/null +++ b/components/accessibility/__tests__/accessibility-quick-panel.test.tsx @@ -0,0 +1,74 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const pathname = vi.hoisted(() => ({ value: '/mail' })); +const push = vi.hoisted(() => vi.fn()); +vi.mock('next-intl', () => ({ + useTranslations: (ns?: string) => (key: string) => `${ns}.${key}`, +})); +vi.mock('@/i18n/navigation', () => ({ + usePathname: () => pathname.value, + useRouter: () => ({ push }), +})); + +import { AccessibilityQuickPanelHost, isAccessibilityPanelShortcut, openAccessibilityPanel } from '../accessibility-quick-panel'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +const pressAltShiftA = () => + fireEvent.keyDown(window, { key: 'A', code: 'KeyA', altKey: true, shiftKey: true }); + +beforeEach(() => { + pathname.value = '/mail'; + push.mockReset(); + useSettingsStore.getState().resetToDefaults(); + useAuthStore.setState({ isAuthenticated: true } as never); +}); + +describe('accessibility quick panel', () => { + it('opens with Alt+Shift+A, also with single-key shortcuts off', () => { + useSettingsStore.getState().updateSetting('singleKeyShortcuts', false); + render(); + expect(screen.queryByTestId('accessibility-panel')).toBeNull(); + pressAltShiftA(); + expect(screen.getByTestId('accessibility-panel')).toBeTruthy(); + }); + + it('matches the physical key, not the character a layout produces', () => { + expect(isAccessibilityPanelShortcut(new KeyboardEvent('keydown', { key: 'Å', code: 'KeyA', altKey: true, shiftKey: true }))).toBe(true); + expect(isAccessibilityPanelShortcut(new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', altKey: true }))).toBe(false); + expect(isAccessibilityPanelShortcut(new KeyboardEvent('keydown', { key: 'A', code: 'KeyA', altKey: true, shiftKey: true, ctrlKey: true }))).toBe(false); + }); + + it('changes text size from the panel', () => { + render(); + act(() => openAccessibilityPanel()); + const select = screen.getByTestId('accessibility-panel').querySelector('select') as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'x-large' } }); + expect(useSettingsStore.getState().fontSize).toBe('x-large'); + }); + + it('shows a button on the sign-in page with text size, contrast and motion only', () => { + useAuthStore.setState({ isAuthenticated: false } as never); + pathname.value = '/login'; + render(); + fireEvent.click(screen.getByTestId('login-accessibility-button')); + const panel = screen.getByTestId('accessibility-panel'); + expect(panel.textContent).toContain('settings.accessibility.text_size.label'); + expect(panel.textContent).toContain('settings.accessibility.contrast.label'); + expect(panel.textContent).not.toContain('settings.accessibility.presets.title'); + expect(panel.textContent).not.toContain('accessibility_panel.all_settings'); + }); + + it('has no sign-in button inside the app', () => { + render(); + expect(screen.queryByTestId('login-accessibility-button')).toBeNull(); + }); + + it('goes to the settings page through the app router', () => { + render(); + act(() => openAccessibilityPanel()); + fireEvent.click(screen.getByRole('button', { name: 'accessibility_panel.all_settings' })); + expect(push).toHaveBeenCalledWith('/settings/accessibility'); + }); +}); diff --git a/components/accessibility/accessibility-quick-panel.tsx b/components/accessibility/accessibility-quick-panel.tsx new file mode 100644 index 000000000..900c523fb --- /dev/null +++ b/components/accessibility/accessibility-quick-panel.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import { Dialog } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Accessibility } from '@/components/icons'; +import { RadioGroup, Select, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { FONT_SIZES, type FontSize } from '@/lib/accessibility-settings'; +import { useRegisterCommands } from '@/lib/command-registry'; +import { ACCESSIBILITY_PRESET_IDS, presetChanges, type AccessibilityPresetId, type PresetChange } from '@/lib/accessibility-presets'; + +export const ACCESSIBILITY_PANEL_EVENT = 'bulwark:accessibility-panel'; + +/** Opens the quick accessibility panel from anywhere (rail button, palette). */ +export function openAccessibilityPanel(): void { + if (typeof window !== 'undefined') window.dispatchEvent(new Event(ACCESSIBILITY_PANEL_EVENT)); +} + +/** + * Alt+Shift+A, by physical key so it works on every keyboard layout. A + * modifier shortcut, so it stays on when single-key shortcuts are off. + */ +export function isAccessibilityPanelShortcut(event: KeyboardEvent): boolean { + return event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyA'; +} + +type SettingKey = Parameters['updateSetting']>[0]; + +function QuickPanel({ isOpen, onClose, signedIn }: { isOpen: boolean; onClose: () => void; signedIn: boolean }) { + const t = useTranslations('accessibility_panel'); + const tA = useTranslations('settings.accessibility'); + const settings = useSettingsStore(); + const { isSettingLocked } = usePolicyStore(); + const [applied, setApplied] = useState<{ id: AccessibilityPresetId; changes: PresetChange[] } | null>(null); + const set = (key: string, value: unknown) => settings.updateSetting(key as SettingKey, value as never); + + const applyPreset = (id: AccessibilityPresetId) => { + const changes = presetChanges(id, useSettingsStore.getState() as unknown as Record, isSettingLocked); + for (const change of changes) set(change.key, change.to); + setApplied({ id, changes }); + }; + + const router = useRouter(); + const openSettings = () => { + onClose(); + router.push('/settings/accessibility'); + }; + + return ( + +
+ +