From e57e9229507d07da821f7c42fec3bc0cf0823b3c Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 29 Oct 2026 18:45:07 +0100
Subject: [PATCH] feat: make buttons, menu items, rows and switches 44px when
large targets is on
---
app/accessibility.css | 36 +++++++++++++++++++
app/theme-inline.css | 12 +++++++
.../__tests__/more-actions-menu.test.tsx | 2 +-
components/files/file-views.tsx | 3 +-
components/files/files-header.tsx | 4 +--
.../sidebar-folder-touch-menu.test.tsx | 2 +-
.../layout/__tests__/touch-targets.test.tsx | 2 +-
.../calendar-management-phone.test.tsx | 4 +--
.../__tests__/settings-touch-targets.test.tsx | 2 +-
components/ui/__tests__/button.test.tsx | 2 +-
components/ui/button.tsx | 18 +++++-----
...-css.test.ts => accessibility-css.test.ts} | 27 ++++++++++++++
12 files changed, 96 insertions(+), 18 deletions(-)
rename lib/__tests__/{accessibility-focus-css.test.ts => accessibility-css.test.ts} (81%)
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();
const button = screen.getByLabelText('Hit');
- expect(button).toHaveClass('relative', 'pointer-coarse:after:min-h-11', 'pointer-coarse:after:min-w-11');
+ expect(button).toHaveClass('relative', 'large-targets:after:min-h-11', 'large-targets:after:min-w-11');
expect(button.className).not.toMatch(/(^|\s)after:/);
});
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index 0fb94f6bf..fa2ca8812 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -22,15 +22,17 @@ const SIZES: Record = {
"icon-lg": "h-11 w-11 [:where(&)_svg]:size-5",
};
-// On a touch screen every button can be hit within 44x44 px, whatever size
-// it is drawn at: a transparent ::after, centred on the button, at least
-// that big. `relative` gives it something to centre on; a className with
-// another position (absolute, fixed, sticky) replaces it through
-// tailwind-merge and still holds the ::after.
+// On a touch screen, and with a mouse when Settings > Accessibility > Large
+// targets is On, every button can be hit within 44x44 px, whatever size it
+// is drawn at: a transparent ::after, centred on the button, at least that
+// big. `relative` gives it something to centre on; a className with another
+// position (absolute, fixed, sticky) replaces it through tailwind-merge and
+// still holds the ::after. The large-targets variant is defined in
+// app/theme-inline.css (and as plain pointer: coarse in the plugin kit).
export const TOUCH_HIT_AREA =
- "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";
+ "relative large-targets:after:absolute large-targets:after:top-1/2 large-targets:after:left-1/2 " +
+ "large-targets:after:size-full large-targets:after:min-h-11 large-targets:after:min-w-11 " +
+ "large-targets:after:-translate-x-1/2 large-targets:after:-translate-y-1/2";
const Button = React.forwardRef(
({ className, variant = "default", size = "md", ...props }, ref) => {
diff --git a/lib/__tests__/accessibility-focus-css.test.ts b/lib/__tests__/accessibility-css.test.ts
similarity index 81%
rename from lib/__tests__/accessibility-focus-css.test.ts
rename to lib/__tests__/accessibility-css.test.ts
index 92bcd9c42..e75842ce0 100644
--- a/lib/__tests__/accessibility-focus-css.test.ts
+++ b/lib/__tests__/accessibility-css.test.ts
@@ -133,3 +133,30 @@ describe('forced colours', () => {
expect(block).toContain(':is(span, div, i):empty[style*="background"] {\n forced-color-adjust: none;');
});
});
+
+describe('large targets', () => {
+ const tall = selectorsOf('min-height: 44px;').join(', ');
+ const wide = selectorsOf('min-width: 44px;').join(', ');
+ const strip = (sel: string) => sel.replace(/:root\[data-large-targets="on"\]\s*/g, '');
+
+ it.each([
+ ['a button', ''],
+ ['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);
+ });
+});