feat: make buttons, menu items, rows and switches 44px when large targets is on

This commit is contained in:
Linus Rath
2026-10-29 19:38:45 +01:00
parent f43eb9f41a
commit e57e922950
12 changed files with 96 additions and 18 deletions
+36
View File
@@ -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"],
+12
View File
@@ -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);
@@ -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');
});
});
+2 -1
View File
@@ -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).
+2 -2
View File
@@ -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",
)}
>
@@ -79,7 +79,7 @@ describe('folder list on touch', () => {
const withChild = [...mailboxes, { ...box('trips', 'Trips', null), parentId: 'travel' } as Mailbox];
render(<Sidebar mailboxes={withChild} selectedMailbox="inbox" />);
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();
});
@@ -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', () => {
@@ -50,8 +50,8 @@ describe('Settings > Calendar on a phone', () => {
it('gives the colour dot a 44px hit area on touch', () => {
render(<CalendarManagementSettings />);
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', () => {
@@ -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', () => {
+1 -1
View File
@@ -46,7 +46,7 @@ describe('Button', () => {
it('keeps the drawn size and adds a 44px hit area on coarse pointers only', () => {
render(<Button size="icon-sm" aria-label="Hit" />);
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:/);
});
+10 -8
View File
@@ -22,15 +22,17 @@ const SIZES: Record<ButtonSize, string> = {
"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<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", ...props }, ref) => {
@@ -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', '<button>Reply</button>'],
['a menu item', '<div role="menuitem">Archive</div>'],
['an option', '<div role="option">Inbox</div>'],
['a tab', '<button role="tab">All</button>'],
['a custom checkbox', '<button role="checkbox" aria-checked="false"></button>'],
])('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('<button role="switch" aria-checked="true"><span></span></button>');
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);
});
});