Skip to content

Commit

Permalink
Target size feature (#248)
Browse files Browse the repository at this point in the history
Co-authored-by: Paul Hebert <[email protected]>
Co-authored-by: Gerardo Rodriguez <[email protected]>
Co-authored-by: Caleb Eby <[email protected]>
Co-authored-by: Caleb Eby <[email protected]>
  • Loading branch information
5 people authored Mar 31, 2022
1 parent 5fa4103 commit abe22a6
Show file tree
Hide file tree
Showing 12 changed files with 584 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .changeset/soft-rivers-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'pleasantest': major
---

Enforce minimum target size when calling `user.click()`, per WCAG Success Criterion 2.5.5 Target Size guideline.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ dist
node_modules
.browser-cache.json
.cache
.vscode
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,12 @@ Ensures that the element is visible to a user. Currently, the following checks a
- 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)
- Element's opacity is greater than 0.05 (opacity of parent elements are considered)

#### Target size

> [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)
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`).

## Full Example

There is a menu example in the [examples folder](./examples/menu/index.test.ts)
Expand Down Expand Up @@ -362,7 +368,9 @@ Call Signatures:
- `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).
- `plugins`: Array of Rollup, Vite, or WMR plugins to add.
- `envVars`: Object with string keys and string values for environment variables to pass in as `import.meta.env.*` / `process.env.*`
- `esbuild`: [`TransformOptions`](https://esbuild.github.io/api/#transform-api) | `false`: Options to pass to esbuild. Set to false to disable esbuild.
- `esbuild`: ([`TransformOptions`](https://esbuild.github.io/api/#transform-api) | `false`) Options to pass to esbuild. Set to false to disable esbuild.
- `user`: User API options object (all properties are optional). They will be applied when calling `user.*` methods.
- `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.

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:

Expand All @@ -374,6 +382,9 @@ configureDefaults({
moduleServer: {
/* ... */
},
user: {
/* ... */
},
/* ... */
})
```
Expand Down Expand Up @@ -525,11 +536,13 @@ See the [`PleasantestUtils`](#utilities-api-pleasantestutils) documentation.

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).

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

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!

**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 }`.
**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 }`.

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).

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

Expand Down
111 changes: 111 additions & 0 deletions docs/errors/target-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Error: Cannot click element that is too small

## Background/Intent

This error is intended to encourage developers and designers to use target sizes that are large enough for users to easily click or touch.

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.

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.

## Implementation

Pleasantest implements the target size check as a part of [actionability checks](../../README.md#actionability). Target size is checked when `user.click()` is called.

Inline elements are not checked, based on the reasoning used [by the W3C](https://www.w3.org/WAI/WCAG21/Understanding/target-size.html#intent).

Elements must be at least 44px × 44px in order to pass the check (or whatever the configured target size is).

### Configuring the minimum target size

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:

**On an individual call**:

```ts
await user.click(button, { targetSize: 30 /* px */ });
```

**For a single test**:

```ts
test(
'test name',
withBrowser(
{
user: {
targetSize: 30 /* px */,
},
},
async ({ user }) => {
await user.click(something);
},
),
);
```

**For a test file**:

```ts
import { configureDefaults } from 'pleasantest';

configureDefaults({
user: { targetSize: 50 /* px */ },
});
```

**For all test files**

[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.

## Making the error go away

### Approach 1: Increasing the target size

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.

Other resources:

- https://css-tricks.com/looking-at-wcag-2-5-5-for-better-target-sizes/

### Approach 2: Disabling the check

Pleasantest's target size check can be disabled per-call, per-file, or globally.

**On an individual call**:

```ts
await user.click(button, { targetSize: false });
```

**For a single test**:

```ts
test(
'test name',
withBrowser(
{
user: {
targetSize: false,
},
},
async ({ user }) => {
await user.click(something);
},
),
);
```

**For a test file**:

```ts
import { configureDefaults } from 'pleasantest';

configureDefaults({
user: { targetSize: false },
});
```

**For all test files**

[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.
1 change: 1 addition & 0 deletions examples/menu/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ a {
transition: color 0.2s ease;
background: transparent;
border: none;
padding: 1em;
}

.menu-link > :is(a, button):is(:hover, :focus) {
Expand Down
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { bgRed, white, options as koloristOpts, bold, red } from 'kolorist';
import { ansiColorsLog } from './ansi-colors-browser';
import _ansiRegex from 'ansi-regex';
import { fileURLToPath } from 'url';
import type { PleasantestUser } from './user';
import type { PleasantestUser, UserOpts } from './user';
import { pleasantestUser } from './user';
import { assertElementHandle } from './utils';
import type { ModuleServerOpts } from './module-server';
Expand Down Expand Up @@ -68,6 +68,7 @@ export interface WithBrowserOpts {
headless?: boolean;
device?: puppeteer.devices.Device;
moduleServer?: ModuleServerOpts;
user?: UserOpts;
}

interface TestFn {
Expand Down Expand Up @@ -223,6 +224,7 @@ const createTab = async ({
headless = defaultOptions.headless ?? true,
device = defaultOptions.device,
moduleServer: moduleServerOpts = {},
user: userOpts = {},
},
}: {
testPath: string;
Expand All @@ -244,6 +246,8 @@ const createTab = async ({
} = await createModuleServer({
...defaultOptions.moduleServer,
...moduleServerOpts,
...defaultOptions.moduleServer,
...moduleServerOpts,
});

if (device) {
Expand Down Expand Up @@ -442,7 +446,10 @@ const createTab = async ({
page,
within,
waitFor,
user: await pleasantestUser(page, asyncHookTracker),
user: await pleasantestUser(page, asyncHookTracker, {
...defaultOptions.user,
...userOpts,
}),
asyncHookTracker,
cleanupServer: () => closeServer(),
};
Expand Down
98 changes: 95 additions & 3 deletions src/user-util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,92 @@ ${el}`;
}
};

export const assertTargetSize = (
el: Element,
targetSize: number | true | undefined,
) => {
// Per W3C recommendation, inline elements are excluded from a min target size
// See: https://www.w3.org/WAI/WCAG21/Understanding/target-size.html
if (getComputedStyle(el).display === 'inline') {
return;
}

const { width, height } = el.getBoundingClientRect();
const minSize = typeof targetSize === 'number' ? targetSize : 44;

const elDescriptor =
el instanceof HTMLInputElement ? `${el.type} input` : 'element';

// Why is this hardcoded?
// So that the snapshots do not fail when a new version is released and all the error messages change
// Why is this not pointing to `main`?
// So that if the docs are moved around or renamed in the future, the links in previous PT versions still work
// Does this need to be updated before every release?
// No, only when the docs are changed
const docsVersion = 'v2.0.0';

const targetSizeError = (suggestion: string | InterpolableIntoError[] = '') =>
error`Cannot click element that is too small.
Target size of ${elDescriptor} is smaller than ${
typeof targetSize === 'number'
? `configured minimum of ${minSize}px × ${minSize}px`
: 'W3C recommendation of 44px × 44px: https://www.w3.org/WAI/WCAG21/Understanding/target-size.html'
}
${capitalizeText(elDescriptor)} was ${width}px × ${height}px
${el}${suggestion}
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`;

if (width < minSize || height < minSize) {
// Custom messaging for inputs that should have labels (e.g. type="radio").
//
// Inputs that aren't expected to have labels (e.g. type="submit")
// are checked by the general element check.
//
// MDN <input> docs were referenced
// and the following were assumed to not have labels:
// - type="submit"
// - type="button"
// - type="reset"
//
// @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
if (
el instanceof HTMLInputElement &&
el.type !== 'submit' &&
el.type !== 'button' &&
el.type !== 'reset'
) {
const labelSize = el.labels?.[0]?.getBoundingClientRect();

// Element did not have label
if (!labelSize) {
throw targetSizeError(`
You can increase the target size of the ${elDescriptor} by adding a label that is larger than ${minSize}px × ${minSize}px`);
}

// If label is valid
if (labelSize.width >= minSize && labelSize.height >= minSize) return;

// Element and label was too small
throw targetSizeError(
// The error template tag is used here
// so that the interpolated element (label name) does not get stringified.
error`
Label associated with the ${elDescriptor} was ${labelSize.width}px × ${
labelSize.height
}px
${el.labels![0]}
You can increase the target size by making the label or ${elDescriptor} larger than ${minSize}px × ${minSize}px.`
.error,
);
}

// General element messaging
throw targetSizeError();
}
};

type InterpolableIntoError = Element | string | number | boolean;

// This is used to generate the arrays that are used
// to produce messages with live elements in the browser,
// and stringified elements in node
Expand All @@ -53,11 +139,17 @@ ${el}`;
// returns { error: ['something bad happened', el]}
export const error = (
literals: TemplateStringsArray,
...placeholders: (Element | string)[]
...placeholders: (InterpolableIntoError | InterpolableIntoError[])[]
) => ({
error: literals.reduce((acc, val, i) => {
if (i !== 0) acc.push(placeholders[i - 1]);
if (i !== 0) {
const item = placeholders[i - 1];
if (Array.isArray(item)) acc.push(...item);
else acc.push(item);
}
if (val !== '') acc.push(val);
return acc;
}, [] as (string | Element)[]),
}, [] as (string | Element | number | boolean)[]),
});

const capitalizeText = (text: string) => text[0].toUpperCase() + text.slice(1);
Loading

0 comments on commit abe22a6

Please sign in to comment.