mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: make buttons, menu items, rows and switches 44px when large targets is on
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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:/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+27
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user