diff --git a/app/accessibility.css b/app/accessibility.css index f9b018c3c..5ebb211ed 100644 --- a/app/accessibility.css +++ b/app/accessibility.css @@ -239,6 +239,42 @@ html.dark[data-contrast="more"] { background-color: var(--color-background) !important; } +/* ---------- Large targets (WCAG 2.5.5) ---------- + * + * On a touch screen the components already size themselves (TOUCH_HIT_AREA + * and the pointer-coarse: utilities, through the large-targets variant in + * theme-inline.css). With "On" and a mouse, the rules below do the same + * for controls that carry no such class: buttons, menu items, options, + * tabs, tree items and list rows grow to 44px, switches keep their drawn + * size and get a 44px hit area around them, checkboxes and radios grow to + * 24px and take the rest from their label. + */ +:root[data-large-targets="on"] { + --touch-row-min-h: 44px; +} +:root[data-large-targets="on"] :is(button, [role="button"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="tab"], [role="treeitem"], [role="row"], [role="checkbox"]:not(input), summary, select):not([role="switch"], [role="separator"], [aria-hidden="true"], [disabled]) { + min-height: 44px; +} +:root[data-large-targets="on"] :is(button, [role="button"], [role="checkbox"]:not(input)):not([role="switch"], [role="separator"], [aria-hidden="true"]) { + min-width: 44px; +} +:root[data-large-targets="on"] [role="switch"] { + position: relative; +} +:root[data-large-targets="on"] [role="switch"]::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: max(100%, 44px); + height: max(100%, 44px); + transform: translate(-50%, -50%); +} +:root[data-large-targets="on"] input:is([type="checkbox"], [type="radio"]) { + min-width: 24px; + min-height: 24px; +} + /* ---------- Large cursor ---------- */ :root[data-large-cursor="on"], diff --git a/app/theme-inline.css b/app/theme-inline.css index 32ab34fc7..36718cb0e 100644 --- a/app/theme-inline.css +++ b/app/theme-inline.css @@ -3,6 +3,18 @@ Tailwind colours, so both generate the same utilities. */ @custom-variant dark (&:where(.dark, .dark *)); +/* Settings > Accessibility > Large targets: on a touch screen (System), or + everywhere when it is On. TOUCH_HIT_AREA (components/ui/button.tsx) uses it. + Plugin frames (app/(sandbox)/plugin-kit.css) have no data-large-targets, + so there it is the touch-screen half only. */ +@custom-variant large-targets { + @media (pointer: coarse) { + @slot; + } + :root[data-large-targets="on"] & { + @slot; + } +} @theme inline { --color-border: var(--color-border); diff --git a/components/email/__tests__/more-actions-menu.test.tsx b/components/email/__tests__/more-actions-menu.test.tsx index c09ba8b14..f42bf6254 100644 --- a/components/email/__tests__/more-actions-menu.test.tsx +++ b/components/email/__tests__/more-actions-menu.test.tsx @@ -208,7 +208,7 @@ describe('phone touch targets (MAIL-18)', () => { expect(close.className).toContain('w-11'); const star = screen.getByTestId('subject-star'); expect(star).toHaveAccessibleName('star'); - expect(star.className).toContain('pointer-coarse:after:min-h-11'); + expect(star.className).toContain('large-targets:after:min-h-11'); }); }); diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index f2c0d4e58..32f00b943 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -11,6 +11,7 @@ import { type IconOptions, } from "@/components/files/file-item-parts"; import type { SortDir, SortKey } from "@/components/files/files-header"; +import { TOUCH_HIT_AREA } from "@/components/ui/button"; export interface FileItemHandlers { /** Selection click (plain, Ctrl/Cmd, Shift) on the item body. */ @@ -110,7 +111,7 @@ function SelectBox({ checked, visible, onToggle, label }: { className={cn( "w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-opacity", // A 44px hit area on touch, as every Button has (R1). - "relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2", + TOUCH_HIT_AREA, checked ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background", // Shown on hover, and also when the keyboard is in the item or on a // touch screen, where there is no hover (WCAG 2.4.7). diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index c3df9381f..a4e90e3d6 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -7,7 +7,7 @@ import { PanelRight, Download, Trash2, Scissors, Copy, Pencil, Share2, Clipboard, MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw, RotateCcw, FolderInput, } from "@/components/icons"; -import { Button } from "@/components/ui/button"; +import { Button, TOUCH_HIT_AREA } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { ToolbarButton, ToolbarSearch, TOOLBAR_ACTIVE } from "@/components/ui/toolbar"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; @@ -346,7 +346,7 @@ export function FilesHeader({ className={cn( "h-full w-7 flex items-center justify-center rounded transition-colors", // A 44px hit area on touch, as every Button has (R1). - "relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2", + TOUCH_HIT_AREA, viewMode === mode ? TOOLBAR_ACTIVE : "text-muted-foreground hover:text-foreground hover:bg-muted", )} > diff --git a/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx b/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx index b64a3852a..c45293071 100644 --- a/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx +++ b/components/layout/__tests__/sidebar-folder-touch-menu.test.tsx @@ -79,7 +79,7 @@ describe('folder list on touch', () => { const withChild = [...mailboxes, { ...box('trips', 'Trips', null), parentId: 'travel' } as Mailbox]; render(); const toggle = row('travel').querySelector('[data-testid="folder-expand-toggle"]')!; - expect(toggle.className).toContain('pointer-coarse:after:min-h-11'); + expect(toggle.className).toContain('large-targets:after:min-h-11'); expect(toggle.getAttribute('aria-label')).toBeTruthy(); }); diff --git a/components/layout/__tests__/touch-targets.test.tsx b/components/layout/__tests__/touch-targets.test.tsx index 3c05903e7..fff7d6323 100644 --- a/components/layout/__tests__/touch-targets.test.tsx +++ b/components/layout/__tests__/touch-targets.test.tsx @@ -19,7 +19,7 @@ const { useSettingsStore } = await import('@/stores/settings-store'); const { useAccountStore } = await import('@/stores/account-store'); const { useAuthStore } = await import('@/stores/auth-store'); -const HIT = 'pointer-coarse:after:min-h-11'; +const HIT = 'large-targets:after:min-h-11'; describe('touch targets', () => { it('notification sheet header buttons', () => { diff --git a/components/settings/__tests__/calendar-management-phone.test.tsx b/components/settings/__tests__/calendar-management-phone.test.tsx index f9f67fbc6..79b296605 100644 --- a/components/settings/__tests__/calendar-management-phone.test.tsx +++ b/components/settings/__tests__/calendar-management-phone.test.tsx @@ -50,8 +50,8 @@ describe('Settings > Calendar on a phone', () => { it('gives the colour dot a 44px hit area on touch', () => { render(); const dot = screen.getByRole('button', { name: 'change_color' }); - expect(dot.className).toContain('pointer-coarse:after:min-h-11'); - expect(dot.className).toContain('pointer-coarse:after:min-w-11'); + expect(dot.className).toContain('large-targets:after:min-h-11'); + expect(dot.className).toContain('large-targets:after:min-w-11'); }); it('offers Import and Subscribe only outside the demo', () => { diff --git a/components/settings/__tests__/settings-touch-targets.test.tsx b/components/settings/__tests__/settings-touch-targets.test.tsx index 315ceab92..47c4b8d7e 100644 --- a/components/settings/__tests__/settings-touch-targets.test.tsx +++ b/components/settings/__tests__/settings-touch-targets.test.tsx @@ -5,7 +5,7 @@ import { SectionNav, Select, SettingItem, SettingsSection, ToggleSwitch } from ' // SET-22: on the phone, settings switches (44x24), jump chips (28px) and // selects (29px) were smaller than the 44px a finger needs (R1). -const HIT = 'pointer-coarse:after:min-h-11'; +const HIT = 'large-targets:after:min-h-11'; describe('settings touch targets', () => { it('switches keep their size and get a 44px hit area', () => { diff --git a/components/ui/__tests__/button.test.tsx b/components/ui/__tests__/button.test.tsx index 73acdd590..90ae0789e 100644 --- a/components/ui/__tests__/button.test.tsx +++ b/components/ui/__tests__/button.test.tsx @@ -46,7 +46,7 @@ describe('Button', () => { it('keeps the drawn size and adds a 44px hit area on coarse pointers only', () => { render('], + ['a menu item', '
Archive
'], + ['an option', '
Inbox
'], + ['a tab', ''], + ['a custom checkbox', ''], + ])('makes %s 44px tall when On', (_name, html) => { + expect(el(html).matches(strip(tall))).toBe(true); + }); + + it('keeps the switch at its drawn size and gives it a 44px hit area instead', () => { + const sw = el(''); + expect(sw.matches(strip(tall))).toBe(false); + expect(sw.matches(strip(wide))).toBe(false); + expect(css).toMatch(/:root\[data-large-targets="on"\] \[role="switch"\]::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + }); + + it('applies only when the setting is On; System is left to the touch-screen styles', () => { + for (const s of selectorsOf('min-height: 44px;')) expect(s.startsWith(':root[data-large-targets="on"]')).toBe(true); + }); +});