Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions packages/mobile/jest/accessibility/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,11 @@ function getTypeName(type: ComponentType): string {
}

/**
* Check if a component type is a pressable element.
* Includes TouchableHighlight, TouchableOpacity, TouchableNativeFeedback,
* TouchableWithoutFeedback, and Pressable.
* Type-only check: whether a component type looks like a pressable element.
* Used internally; rules should use the node-based `isPressable` to avoid
* matching both a CDS wrapper and its inner native pressable in the same tree.
*/
export function isPressable(type: ComponentType): boolean {
// Direct reference comparison for React component instances
function isPressableType(type: ComponentType): boolean {
if (
type === TouchableHighlight ||
type === TouchableOpacity ||
Expand All @@ -60,25 +59,59 @@ export function isPressable(type: ComponentType): boolean {
return true;
}

// String name comparison for host components or named types
const typeName = getTypeName(type);
return PRESSABLE_TYPE_NAMES.some((name) => typeName.includes(name));
}

/**
* Check if a component type is a Text element.
* Type-only check: whether a component type looks like a Text element.
*/
export function isText(type: ComponentType): boolean {
// Direct reference comparison
function isTextType(type: ComponentType): boolean {
if (type === Text) {
return true;
}

// String name comparison
const typeName = getTypeName(type);
return typeName === 'Text';
}

/**
* Check if a node is a pressable element. Returns false for CDS wrapper
* components that contain a leaf pressable (so a single logical control
* is only matched once at the leaf).
*/
export function isPressable(node: TestInstance): boolean {
if (!isPressableType(node.type)) {
return false;
}
const pressablesInTree = node.findAll((n) => isPressableType(n.type));
return pressablesInTree.length === 1;
}

/**
* Check if a node is a Text element. Filters out the CDS `Text` wrapper when
* it sits directly above its host `RNText` and forwards the same a11y-defining
* props verbatim, so a single logical Text is matched once at the host. Outer
* Text nodes that own distinct a11y semantics (e.g. `onPress`, `accessibilityRole`)
* are preserved so legitimate nested Text compositions still surface violations.
*/
export function isText(node: TestInstance): boolean {
if (!isTextType(node.type)) {
return false;
}
const directChildText = node.children.find(
(c): c is TestInstance => typeof c !== 'string' && isTextType(c.type),
);
if (
directChildText &&
directChildText.props.onPress === node.props.onPress &&
directChildText.props.accessibilityRole === node.props.accessibilityRole &&
directChildText.props.accessibilityLabel === node.props.accessibilityLabel
) {
return false;
}
return true;
}

/**
* Check if a node is an adjustable component (Slider).
* Returns false for wrapper components that contain a Slider.
Expand All @@ -95,7 +128,7 @@ export function isAdjustable(node: TestInstance): boolean {
* Check if a node is a checkbox (pressable with role="checkbox").
*/
export function isCheckbox(node: TestInstance): boolean {
return isPressable(node.type) && node.props.accessibilityRole === 'checkbox';
return isPressable(node) && node.props.accessibilityRole === 'checkbox';
}

/**
Expand Down
12 changes: 6 additions & 6 deletions packages/mobile/jest/accessibility/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const ALLOWED_CHECKED_VALUES_MESSAGE = ALLOWED_CHECKED_VALUES.join(' or ');
*/
const pressableRoleRequired: Rule = {
id: 'pressable-role-required',
matcher: (node) => isPressable(node.type),
matcher: (node) => isPressable(node),
assertion: (node) => ALLOWED_PRESSABLE_ROLES.includes(node.props.accessibilityRole),
help: {
problem:
Expand All @@ -63,7 +63,7 @@ const pressableRoleRequired: Rule = {
*/
const pressableAccessibleRequired: Rule = {
id: 'pressable-accessible-required',
matcher: (node) => isPressable(node.type),
matcher: (node) => isPressable(node),
assertion: (node) => node.props.accessible !== false,
help: {
problem: 'This button is not accessible (selectable) to the user',
Expand All @@ -78,7 +78,7 @@ const pressableAccessibleRequired: Rule = {
*/
const pressableLabelRequired: Rule = {
id: 'pressable-label-required',
matcher: (node) => isPressable(node.type),
matcher: (node) => isPressable(node),
assertion: (node) => {
const textNode = findTextNode(node);
const textContent = textNode?.props?.children;
Expand Down Expand Up @@ -163,7 +163,7 @@ const adjustableValueRequired: Rule = {
*/
const linkRoleRequired: Rule = {
id: 'link-role-required',
matcher: (node) => isText(node.type),
matcher: (node) => isText(node),
assertion: (node) => {
const { onPress, accessibilityRole } = node.props;
if (onPress) {
Expand All @@ -183,7 +183,7 @@ const linkRoleRequired: Rule = {
*/
const linkRoleMisused: Rule = {
id: 'link-role-misused',
matcher: (node) => isText(node.type),
matcher: (node) => isText(node),
assertion: (node) => {
const { onPress, accessibilityRole } = node.props;
if (!onPress) {
Expand All @@ -203,7 +203,7 @@ const linkRoleMisused: Rule = {
*/
const noEmptyText: Rule = {
id: 'no-empty-text',
matcher: (node) => isText(node.type),
matcher: (node) => isText(node),
assertion: (node) => !!node.props?.children,
help: {
problem: "This text node doesn't contain text and so no accessibility label can be inferred",
Expand Down
80 changes: 40 additions & 40 deletions packages/mobile/src/accordion/AccordionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef, memo, useCallback } from 'react';
import React, { memo, useCallback } from 'react';
import type { View } from 'react-native';
import { useAccordionContext } from '@coinbase/cds-common/accordion/AccordionProvider';
import {
Expand Down Expand Up @@ -85,45 +85,45 @@ export type AccordionHeaderProps = AccordionHeaderBaseProps;
* Composes an Accordion Media, Title, and Icon.
*/
export const AccordionHeader = memo(
forwardRef(
(
{ itemKey, title, subtitle, onPress, media, collapsed, testID }: AccordionHeaderProps,
forwardedRef: React.ForwardedRef<View>,
) => {
const { setActiveKey, activeKey } = useAccordionContext();
const spacing = useCellSpacing();
const accessibilityLabel = subtitle ? `${title}, ${subtitle}` : title;
({
ref: forwardedRef,
itemKey,
title,
subtitle,
onPress,
media,
collapsed,
testID,
}: AccordionHeaderProps & {
ref?: React.Ref<View>;
}) => {
const { setActiveKey, activeKey } = useAccordionContext();
const spacing = useCellSpacing();
const accessibilityLabel = subtitle ? `${title}, ${subtitle}` : title;

const handlePress = useCallback(() => {
onPress?.(itemKey);
setActiveKey(itemKey === activeKey ? null : itemKey);
}, [onPress, itemKey, setActiveKey, activeKey]);
const handlePress = useCallback(() => {
onPress?.(itemKey);
setActiveKey(itemKey === activeKey ? null : itemKey);
}, [onPress, itemKey, setActiveKey, activeKey]);

return (
<Pressable
ref={forwardedRef}
noScaleOnPress
transparentWhileInactive
accessibilityLabel={accessibilityLabel}
accessibilityRole="togglebutton"
accessibilityState={{ expanded: !collapsed }}
background="bg"
onPress={handlePress}
testID={testID}
>
<HStack
alignItems="center"
gap={2}
minHeight={listHeight}
width="100%"
{...spacing.outer}
>
{!!media && <AccordionMedia media={media} />}
<AccordionTitle subtitle={subtitle} title={title} />
<AccordionIcon collapsed={collapsed} />
</HStack>
</Pressable>
);
},
),
return (
<Pressable
ref={forwardedRef}
noScaleOnPress
transparentWhileInactive
accessibilityLabel={accessibilityLabel}
accessibilityRole="togglebutton"
accessibilityState={{ expanded: !collapsed }}
background="bg"
onPress={handlePress}
testID={testID}
>
<HStack alignItems="center" gap={2} minHeight={listHeight} width="100%" {...spacing.outer}>
{!!media && <AccordionMedia media={media} />}
<AccordionTitle subtitle={subtitle} title={title} />
<AccordionIcon collapsed={collapsed} />
</HStack>
</Pressable>
);
},
);
40 changes: 21 additions & 19 deletions packages/mobile/src/accordion/AccordionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef, memo } from 'react';
import React, { memo } from 'react';
import type { View } from 'react-native';
import { accordionVisibleMaxHeight } from '@coinbase/cds-common/animation/accordion';
import { accordionSpacing } from '@coinbase/cds-common/tokens/accordion';
Expand All @@ -23,22 +23,24 @@ export type AccordionPanelProps = AccordionPanelBaseProps;
* Accepts a unique `itemKey` prop to uniquely identify one panel from another.
*/
export const AccordionPanel = memo(
forwardRef(
(
{ children, collapsed = true, testID }: AccordionPanelProps,
forwardedRef: React.ForwardedRef<View>,
) => {
return (
<Collapsible
ref={forwardedRef}
collapsed={collapsed}
maxHeight={accordionVisibleMaxHeight}
testID={testID}
{...accordionSpacing}
>
{children}
</Collapsible>
);
},
),
({
ref: forwardedRef,
children,
collapsed = true,
testID,
}: AccordionPanelProps & {
ref?: React.Ref<View>;
}) => {
return (
<Collapsible
ref={forwardedRef}
collapsed={collapsed}
maxHeight={accordionVisibleMaxHeight}
testID={testID}
{...accordionSpacing}
>
{children}
</Collapsible>
);
},
);
Loading
Loading