Skip to content

Commit

Permalink
feat: support Switch checked state (#1657)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdjastrzebski committed Sep 6, 2024
1 parent bf7ea4c commit 856332c
Show file tree
Hide file tree
Showing 7 changed files with 155 additions and 20 deletions.
24 changes: 21 additions & 3 deletions src/helpers/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
} from 'react-native';
import { ReactTestInstance } from 'react-test-renderer';
import { getHostSiblings, getUnsafeRootElement } from './component-tree';
import { getHostComponentNames, isHostText, isHostTextInput } from './host-component-names';
import {
getHostComponentNames,
isHostSwitch,
isHostText,
isHostTextInput,
} from './host-component-names';
import { getTextContent } from './text-content';
import { isTextInputEditable } from './text-input';

Expand Down Expand Up @@ -154,12 +159,17 @@ export function computeAriaBusy({ props }: ReactTestInstance): boolean {

// See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#checked-state
export function computeAriaChecked(element: ReactTestInstance): AccessibilityState['checked'] {
const { props } = element;

if (isHostSwitch(element)) {
return props.value;
}

const role = getRole(element);
if (role !== 'checkbox' && role !== 'radio') {
if (!rolesSupportingCheckedState[role]) {
return undefined;
}

const props = element.props;
return props['aria-checked'] ?? props.accessibilityState?.checked;
}

Expand Down Expand Up @@ -217,3 +227,11 @@ export function computeAccessibleName(element: ReactTestInstance): string | unde

return getTextContent(element);
}

type RoleSupportMap = Partial<Record<Role | AccessibilityRole, true>>;

export const rolesSupportingCheckedState: RoleSupportMap = {
checkbox: true,
radio: true,
switch: true,
};
114 changes: 108 additions & 6 deletions src/matchers/__tests__/to-be-checked.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { type AccessibilityRole, View } from 'react-native';
import { type AccessibilityRole, Switch, View } from 'react-native';
import render from '../../render';
import { screen } from '../../screen';
import '../extend-expect';
Expand Down Expand Up @@ -30,7 +30,56 @@ function renderViewsWithRole(role: AccessibilityRole) {
);
}

test('toBeCheck() with checkbox role', () => {
test('toBeCheck() with Switch', () => {
render(
<>
<Switch testID="checked" value={true} />
<Switch testID="unchecked" value={false} />
<Switch testID="default" />
</>,
);

const checked = screen.getByTestId('checked');
const unchecked = screen.getByTestId('unchecked');
const defaultView = screen.getByTestId('default');

expect(checked).toBeChecked();
expect(unchecked).not.toBeChecked();
expect(defaultView).not.toBeChecked();

expect(() => expect(checked).not.toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toBeChecked()
Received element is checked:
<RCTSwitch
accessibilityRole="switch"
testID="checked"
value={true}
/>"
`);
expect(() => expect(unchecked).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<RCTSwitch
accessibilityRole="switch"
testID="unchecked"
value={false}
/>"
`);
expect(() => expect(defaultView).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<RCTSwitch
accessibilityRole="switch"
testID="default"
value={false}
/>"
`);
});

test('toBeCheck() with "checkbox" role', () => {
renderViewsWithRole('checkbox');

const checked = screen.getByTestId('checkbox-checked');
Expand Down Expand Up @@ -100,7 +149,7 @@ test('toBeCheck() with checkbox role', () => {
`);
});

test('toBeCheck() with radio role', () => {
test('toBeCheck() with "radio" role', () => {
renderViewsWithRole('radio');

const checked = screen.getByTestId('radio-checked');
Expand Down Expand Up @@ -153,17 +202,70 @@ test('toBeCheck() with radio role', () => {
`);
});

test('toBeCheck() with "switch" role', () => {
renderViewsWithRole('switch');

const checked = screen.getByTestId('switch-checked');
const unchecked = screen.getByTestId('switch-unchecked');
const defaultView = screen.getByTestId('switch-default');

expect(checked).toBeChecked();
expect(unchecked).not.toBeChecked();
expect(defaultView).not.toBeChecked();

expect(() => expect(checked).not.toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toBeChecked()
Received element is checked:
<View
accessibilityRole="switch"
accessibilityState={
{
"checked": true,
}
}
accessible={true}
testID="switch-checked"
/>"
`);
expect(() => expect(unchecked).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<View
accessibilityRole="switch"
accessibilityState={
{
"checked": false,
}
}
accessible={true}
testID="switch-unchecked"
/>"
`);
expect(() => expect(defaultView).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<View
accessibilityRole="switch"
accessible={true}
testID="switch-default"
/>"
`);
});

test('throws error for invalid role', () => {
renderViewsWithRole('adjustable');

const checked = screen.getByTestId('adjustable-checked');
const unchecked = screen.getByTestId('adjustable-unchecked');

expect(() => expect(checked).toBeChecked()).toThrowErrorMatchingInlineSnapshot(
`"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."`,
`"toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role."`,
);
expect(() => expect(unchecked).not.toBeChecked()).toThrowErrorMatchingInlineSnapshot(
`"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."`,
`"toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role."`,
);
});

Expand All @@ -172,6 +274,6 @@ test('throws error for non-accessibility element', () => {

const view = screen.getByTestId('test');
expect(() => expect(view).toBeChecked()).toThrowErrorMatchingInlineSnapshot(
`"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."`,
`"toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role."`,
);
});
16 changes: 11 additions & 5 deletions src/matchers/to-be-checked.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import type { ReactTestInstance } from 'react-test-renderer';
import { matcherHint } from 'jest-matcher-utils';
import { computeAriaChecked, getRole, isAccessibilityElement } from '../helpers/accessibility';
import {
computeAriaChecked,
getRole,
isAccessibilityElement,
rolesSupportingCheckedState,
} from '../helpers/accessibility';
import { ErrorWithStack } from '../helpers/errors';
import { isHostSwitch } from '../helpers/host-component-names';
import { checkHostElement, formatElement } from './utils';

export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstance) {
checkHostElement(element, toBeChecked, this);

if (!hasValidAccessibilityRole(element)) {
if (!isHostSwitch(element) && !isSupportedAccessibilityElement(element)) {
throw new ErrorWithStack(
`toBeChecked() works only on accessibility elements with "checkbox" or "radio" role.`,
`toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role.`,
toBeChecked,
);
}
Expand All @@ -28,11 +34,11 @@ export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstanc
};
}

function hasValidAccessibilityRole(element: ReactTestInstance) {
function isSupportedAccessibilityElement(element: ReactTestInstance) {
if (!isAccessibilityElement(element)) {
return false;
}

const role = getRole(element);
return role === 'checkbox' || role === 'radio';
return rolesSupportingCheckedState[role];
}
4 changes: 2 additions & 2 deletions src/queries/__tests__/accessibility-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,14 +477,14 @@ describe('aria-checked prop', () => {
});

test('supports aria-checked="mixed" prop', () => {
render(<View accessible accessibilityRole="checkbox" aria-checked="mixed" />);
render(<View accessible role="checkbox" aria-checked="mixed" />);
expect(screen.getByAccessibilityState({ checked: 'mixed' })).toBeTruthy();
expect(screen.queryByAccessibilityState({ checked: true })).toBeNull();
expect(screen.queryByAccessibilityState({ checked: false })).toBeNull();
});

test('supports default aria-checked prop', () => {
render(<View accessible accessibilityRole="checkbox" />);
render(<View accessible role="checkbox" />);
expect(screen.getByAccessibilityState({})).toBeTruthy();
expect(screen.queryByAccessibilityState({ checked: true })).toBeNull();
expect(screen.queryByAccessibilityState({ checked: false })).toBeNull();
Expand Down
11 changes: 10 additions & 1 deletion src/queries/__tests__/role.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
TouchableOpacity,
TouchableWithoutFeedback,
View,
Switch,
} from 'react-native';
import { render, screen } from '../..';

Expand Down Expand Up @@ -426,7 +427,7 @@ describe('supports accessibility states', () => {
expect(screen.queryByRole('checkbox', { checked: 'mixed' })).toBe(null);
});

it('returns `mixed` checkboxes', () => {
test('returns `mixed` checkboxes', () => {
render(
<TouchableOpacity accessibilityRole="checkbox" accessibilityState={{ checked: 'mixed' }} />,
);
Expand Down Expand Up @@ -508,6 +509,14 @@ describe('supports accessibility states', () => {
expect(screen.queryByRole('checkbox', { checked: false })).toBe(null);
});

test('supports "Switch" component', () => {
render(<Switch value={true} />);

expect(screen.getByRole('switch', { checked: true })).toBeTruthy();
expect(screen.queryByRole('switch', { checked: false })).toBe(null);
expect(screen.queryByRole('switch', { checked: 'mixed' })).toBe(null);
});

test('supports aria-checked={true} prop', () => {
render(<View accessible role="checkbox" aria-checked={true} />);
expect(screen.getByRole('checkbox', { checked: true })).toBeTruthy();
Expand Down
4 changes: 2 additions & 2 deletions website/docs/12.x/docs/api/jest-matchers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ These allow you to assert whether the given element is checked or partially chec

:::note

- `toBeChecked()` matcher works only on elements with the `checkbox` or `radio` role.
- `toBePartiallyChecked()` matcher works only on elements with the `checkbox` role.
- `toBeChecked()` matcher works only on `Switch` host elements and accessibility elements with `checkbox`, `radio` or `switch` role.
- `toBePartiallyChecked()` matcher works only on elements with `checkbox` role.

:::

Expand Down
2 changes: 1 addition & 1 deletion website/docs/12.x/docs/migration/jest-matchers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,5 @@ New [`toHaveAccessibleName()`](docs/api/jest-matchers#tohaveaccessiblename) has
You should be aware of the following details:

- [`toBeEnabled()` / `toBeDisabled()`](docs/api/jest-matchers#tobeenabled) matchers also check the disabled state for the element's ancestors and not only the element itself. This is the same as in legacy Jest Native matchers of the same name but differs from the removed `toHaveAccessibilityState()` matcher.
- [`toBeChecked()`](docs/api/jest-matchers#tobechecked) matcher supports only elements with a `checkbox` or `radio` role
- [`toBeChecked()`](docs/api/jest-matchers#tobechecked) matcher supports only elements with a `checkbox`, `radio` and 'switch' role
- [`toBePartiallyChecked()`](docs/api/jest-matchers#tobechecked) matcher supports only elements with `checkbox` role

0 comments on commit 856332c

Please sign in to comment.