Skip to content

Commit abe22a6

Browse files
gerardo-rodriguezPaul-Hebertcalebeby
authored
Target size feature (#248)
Co-authored-by: Paul Hebert <paul@cloudfour.com> Co-authored-by: Gerardo Rodriguez <gerardo@cloudfour.com> Co-authored-by: Caleb Eby <caleb.eby01@gmail.com> Co-authored-by: Caleb Eby <calebeby@users.noreply.github.com>
1 parent 5fa4103 commit abe22a6

12 files changed

Lines changed: 584 additions & 35 deletions

File tree

.changeset/soft-rivers-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pleasantest': major
3+
---
4+
5+
Enforce minimum target size when calling `user.click()`, per WCAG Success Criterion 2.5.5 Target Size guideline.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ dist
22
node_modules
33
.browser-cache.json
44
.cache
5+
.vscode

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,12 @@ Ensures that the element is visible to a user. Currently, the following checks a
327327
- Element has a size (its [bounding box](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) has a non-zero width and height)
328328
- Element's opacity is greater than 0.05 (opacity of parent elements are considered)
329329

330+
#### Target size
331+
332+
> [The intent of this success criteria is to ensure that target sizes are large enough for users to easily activate them, even if the user is accessing content on a small handheld touch screen device, has limited dexterity, or has trouble activating small targets for other reasons. For instance, mice and similar pointing devices can be hard to use for these users, and a larger target will help them activate the target.](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html)
333+
334+
Per the [W3C Web Content Accessibility Guidelines (WCAG) 2.1](https://www.w3.org/TR/WCAG21/#target-size), the element must be at least 44px wide and at least 44px tall to pass the target size check (configurable via `user.targetSize` option in `WithBrowserOpts` or `targetSize` option for `user.click`).
335+
330336
## Full Example
331337

332338
There is a menu example in the [examples folder](./examples/menu/index.test.ts)
@@ -362,7 +368,9 @@ Call Signatures:
362368
- `moduleServer`: Module Server options object (all properties are optional). They will be applied to files imported through [`utils.runJS`](#pleasantestutilsrunjscode-string-promisevoid) or [`utils.loadJS`](#pleasantestutilsloadjsjspath-string-promisevoid).
363369
- `plugins`: Array of Rollup, Vite, or WMR plugins to add.
364370
- `envVars`: Object with string keys and string values for environment variables to pass in as `import.meta.env.*` / `process.env.*`
365-
- `esbuild`: [`TransformOptions`](https://esbuild.github.io/api/#transform-api) | `false`: Options to pass to esbuild. Set to false to disable esbuild.
371+
- `esbuild`: ([`TransformOptions`](https://esbuild.github.io/api/#transform-api) | `false`) Options to pass to esbuild. Set to false to disable esbuild.
372+
- `user`: User API options object (all properties are optional). They will be applied when calling `user.*` methods.
373+
- `targetSize`: (`number | boolean`, default `44`): Set the minimum target size for `user.click`. Set to `false` to disable target size checks. This option can also be passed to individual `user.click` calls in the 2nd parameter.
366374

367375
You can configure the default options (applied to all tests in current file) by using the `configureDefaults` method. If you want defaults to apply to all files, Create a [test setup file](https://jestjs.io/docs/configuration#setupfilesafterenv-array) and call `configureDefaults` there:
368376

@@ -374,6 +382,9 @@ configureDefaults({
374382
moduleServer: {
375383
/* ... */
376384
},
385+
user: {
386+
/* ... */
387+
},
377388
/* ... */
378389
})
379390
```
@@ -525,11 +536,13 @@ See the [`PleasantestUtils`](#utilities-api-pleasantestutils) documentation.
525536

526537
The user API allows you to perform actions on behalf of the user. If you have used [`user-event`](https://github.com/testing-library/user-event), then this API will feel familiar. This API is exposed via the [`user` property in `PleasantestContext`](#pleasantestcontextuser-pleasantestuser).
527538

528-
#### `PleasantestUser.click(element: ElementHandle, options?: { force?: boolean }): Promise<void>`
539+
#### `PleasantestUser.click(element: ElementHandle, options?: { force?: boolean, targetSize?: number | boolean }): Promise<void>`
529540

530541
Clicks an element, if the element is visible and the center of it is not covered by another element. If the center of the element is covered by another element, an error is thrown. This is a thin wrapper around Puppeteer's [`ElementHandle.click` method](https://pptr.dev/#?product=Puppeteer&version=v13.5.2&show=api-elementhandleclickoptions). The difference is that `PleasantestUser.click` checks that the target element is an element that actually can be clicked before clicking it!
531542

532-
**Actionability checks**: It refuses to click elements that are not [**attached**](#attached) or not [**visible**](#visible). You can override the visibility check by passing `{ force: true }`.
543+
**Actionability checks**: It refuses to click elements that are not [**attached**](#attached), not [**visible**](#visible) or which have too small of a [**target size**](#target-size). You can override the visibility and target size checks by passing `{ force: true }`.
544+
545+
The target size check can be disabled or configured by passing the `targetSize` option in the second parameter. Passing `false` disables the check; passing a number sets the minimum width/height of elements (in px).
533546

534547
Additionally, it refuses to click an element if there is another element covering it. `{ force: true }` overrides this behavior.
535548

docs/errors/target-size.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Error: Cannot click element that is too small
2+
3+
## Background/Intent
4+
5+
This error is intended to encourage developers and designers to use target sizes that are large enough for users to easily click or touch.
6+
7+
An element's **target size** is the size of the clickable/tappable region that activates the element. Having a large-enough target size ensures that users can easily click/tap elements, especially in cases where low-input-precision devices (like touchscreens) are used, and for users who may have difficulty aiming cursors due to fine motor movement challenges.
8+
9+
The [W3C's guidance on target size](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html) is that developers should use target sizes that are at least 44px × 44px.
10+
11+
## Implementation
12+
13+
Pleasantest implements the target size check as a part of [actionability checks](../../README.md#actionability). Target size is checked when `user.click()` is called.
14+
15+
Inline elements are not checked, based on the reasoning used [by the W3C](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html#intent).
16+
17+
Elements must be at least 44px × 44px in order to pass the check (or whatever the configured target size is).
18+
19+
### Configuring the minimum target size
20+
21+
Setting the `targetSize` option changes the minimum width/height (in px) used to check elements when `user.click` is called. The `targetSize` option can be passed in several places to control the scope of the change:
22+
23+
**On an individual call**:
24+
25+
```ts
26+
await user.click(button, { targetSize: 30 /* px */ });
27+
```
28+
29+
**For a single test**:
30+
31+
```ts
32+
test(
33+
'test name',
34+
withBrowser(
35+
{
36+
user: {
37+
targetSize: 30 /* px */,
38+
},
39+
},
40+
async ({ user }) => {
41+
await user.click(something);
42+
},
43+
),
44+
);
45+
```
46+
47+
**For a test file**:
48+
49+
```ts
50+
import { configureDefaults } from 'pleasantest';
51+
52+
configureDefaults({
53+
user: { targetSize: 50 /* px */ },
54+
});
55+
```
56+
57+
**For all test files**
58+
59+
[Configure Jest to run a setup file before all tests](https://jestjs.io/docs/configuration#setupfilesafterenv-array) (usually called `jest.setup.ts` or `jest.setup.js`) and add the same `configureDefaults` call there, so it is applied to all tests.
60+
61+
## Making the error go away
62+
63+
### Approach 1: Increasing the target size
64+
65+
Much of the time, increasing the target size is the correct solution to the problem. By doing this, you are creating a more inclusive user experience. Usually, increasing `padding` or setting a `min-width`/`min-height` is the easiest way to ensure an element's target size is large enough.
66+
67+
Other resources:
68+
69+
- https://css-tricks.com/looking-at-wcag-2-5-5-for-better-target-sizes/
70+
71+
### Approach 2: Disabling the check
72+
73+
Pleasantest's target size check can be disabled per-call, per-file, or globally.
74+
75+
**On an individual call**:
76+
77+
```ts
78+
await user.click(button, { targetSize: false });
79+
```
80+
81+
**For a single test**:
82+
83+
```ts
84+
test(
85+
'test name',
86+
withBrowser(
87+
{
88+
user: {
89+
targetSize: false,
90+
},
91+
},
92+
async ({ user }) => {
93+
await user.click(something);
94+
},
95+
),
96+
);
97+
```
98+
99+
**For a test file**:
100+
101+
```ts
102+
import { configureDefaults } from 'pleasantest';
103+
104+
configureDefaults({
105+
user: { targetSize: false },
106+
});
107+
```
108+
109+
**For all test files**
110+
111+
[Configure Jest to run a setup file before all tests](https://jestjs.io/docs/configuration#setupfilesafterenv-array) (usually called `jest.setup.ts` or `jest.setup.js`) and add the same `configureDefaults` call there, so it is applied to all tests.

examples/menu/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ a {
9898
transition: color 0.2s ease;
9999
background: transparent;
100100
border: none;
101+
padding: 1em;
101102
}
102103

103104
.menu-link > :is(a, button):is(:hover, :focus) {

src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { bgRed, white, options as koloristOpts, bold, red } from 'kolorist';
1212
import { ansiColorsLog } from './ansi-colors-browser';
1313
import _ansiRegex from 'ansi-regex';
1414
import { fileURLToPath } from 'url';
15-
import type { PleasantestUser } from './user';
15+
import type { PleasantestUser, UserOpts } from './user';
1616
import { pleasantestUser } from './user';
1717
import { assertElementHandle } from './utils';
1818
import type { ModuleServerOpts } from './module-server';
@@ -68,6 +68,7 @@ export interface WithBrowserOpts {
6868
headless?: boolean;
6969
device?: puppeteer.devices.Device;
7070
moduleServer?: ModuleServerOpts;
71+
user?: UserOpts;
7172
}
7273

7374
interface TestFn {
@@ -223,6 +224,7 @@ const createTab = async ({
223224
headless = defaultOptions.headless ?? true,
224225
device = defaultOptions.device,
225226
moduleServer: moduleServerOpts = {},
227+
user: userOpts = {},
226228
},
227229
}: {
228230
testPath: string;
@@ -244,6 +246,8 @@ const createTab = async ({
244246
} = await createModuleServer({
245247
...defaultOptions.moduleServer,
246248
...moduleServerOpts,
249+
...defaultOptions.moduleServer,
250+
...moduleServerOpts,
247251
});
248252

249253
if (device) {
@@ -442,7 +446,10 @@ const createTab = async ({
442446
page,
443447
within,
444448
waitFor,
445-
user: await pleasantestUser(page, asyncHookTracker),
449+
user: await pleasantestUser(page, asyncHookTracker, {
450+
...defaultOptions.user,
451+
...userOpts,
452+
}),
446453
asyncHookTracker,
447454
cleanupServer: () => closeServer(),
448455
};

src/user-util/index.ts

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,92 @@ ${el}`;
4545
}
4646
};
4747

48+
export const assertTargetSize = (
49+
el: Element,
50+
targetSize: number | true | undefined,
51+
) => {
52+
// Per W3C recommendation, inline elements are excluded from a min target size
53+
// See: https://www.w3.org/WAI/WCAG21/Understanding/target-size.html
54+
if (getComputedStyle(el).display === 'inline') {
55+
return;
56+
}
57+
58+
const { width, height } = el.getBoundingClientRect();
59+
const minSize = typeof targetSize === 'number' ? targetSize : 44;
60+
61+
const elDescriptor =
62+
el instanceof HTMLInputElement ? `${el.type} input` : 'element';
63+
64+
// Why is this hardcoded?
65+
// So that the snapshots do not fail when a new version is released and all the error messages change
66+
// Why is this not pointing to `main`?
67+
// So that if the docs are moved around or renamed in the future, the links in previous PT versions still work
68+
// Does this need to be updated before every release?
69+
// No, only when the docs are changed
70+
const docsVersion = 'v2.0.0';
71+
72+
const targetSizeError = (suggestion: string | InterpolableIntoError[] = '') =>
73+
error`Cannot click element that is too small.
74+
Target size of ${elDescriptor} is smaller than ${
75+
typeof targetSize === 'number'
76+
? `configured minimum of ${minSize}px × ${minSize}px`
77+
: 'W3C recommendation of 44px × 44px: https://www.w3.org/WAI/WCAG21/Understanding/target-size.html'
78+
}
79+
${capitalizeText(elDescriptor)} was ${width}px × ${height}px
80+
${el}${suggestion}
81+
You can customize this check by setting the targetSize option, more details at https://github.com/cloudfour/pleasantest/blob/${docsVersion}/docs/errors/target-size.md`;
82+
83+
if (width < minSize || height < minSize) {
84+
// Custom messaging for inputs that should have labels (e.g. type="radio").
85+
//
86+
// Inputs that aren't expected to have labels (e.g. type="submit")
87+
// are checked by the general element check.
88+
//
89+
// MDN <input> docs were referenced
90+
// and the following were assumed to not have labels:
91+
// - type="submit"
92+
// - type="button"
93+
// - type="reset"
94+
//
95+
// @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
96+
if (
97+
el instanceof HTMLInputElement &&
98+
el.type !== 'submit' &&
99+
el.type !== 'button' &&
100+
el.type !== 'reset'
101+
) {
102+
const labelSize = el.labels?.[0]?.getBoundingClientRect();
103+
104+
// Element did not have label
105+
if (!labelSize) {
106+
throw targetSizeError(`
107+
You can increase the target size of the ${elDescriptor} by adding a label that is larger than ${minSize}px × ${minSize}px`);
108+
}
109+
110+
// If label is valid
111+
if (labelSize.width >= minSize && labelSize.height >= minSize) return;
112+
113+
// Element and label was too small
114+
throw targetSizeError(
115+
// The error template tag is used here
116+
// so that the interpolated element (label name) does not get stringified.
117+
error`
118+
Label associated with the ${elDescriptor} was ${labelSize.width}px × ${
119+
labelSize.height
120+
}px
121+
${el.labels![0]}
122+
You can increase the target size by making the label or ${elDescriptor} larger than ${minSize}px × ${minSize}px.`
123+
.error,
124+
);
125+
}
126+
127+
// General element messaging
128+
throw targetSizeError();
129+
}
130+
};
131+
132+
type InterpolableIntoError = Element | string | number | boolean;
133+
48134
// This is used to generate the arrays that are used
49135
// to produce messages with live elements in the browser,
50136
// and stringified elements in node
@@ -53,11 +139,17 @@ ${el}`;
53139
// returns { error: ['something bad happened', el]}
54140
export const error = (
55141
literals: TemplateStringsArray,
56-
...placeholders: (Element | string)[]
142+
...placeholders: (InterpolableIntoError | InterpolableIntoError[])[]
57143
) => ({
58144
error: literals.reduce((acc, val, i) => {
59-
if (i !== 0) acc.push(placeholders[i - 1]);
145+
if (i !== 0) {
146+
const item = placeholders[i - 1];
147+
if (Array.isArray(item)) acc.push(...item);
148+
else acc.push(item);
149+
}
60150
if (val !== '') acc.push(val);
61151
return acc;
62-
}, [] as (string | Element)[]),
152+
}, [] as (string | Element | number | boolean)[]),
63153
});
154+
155+
const capitalizeText = (text: string) => text[0].toUpperCase() + text.slice(1);

0 commit comments

Comments
 (0)