Skip to content
Merged
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
30 changes: 26 additions & 4 deletions .github/workflows/visreg-mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,33 @@ jobs:

- name: Boot iOS simulator
run: |
STATE=$(xcrun simctl list devices available --json | jq -r '.devices[] | .[] | select(.name == "iPhone 16") | .state' | head -n 1)
if [ "$STATE" != "Booted" ]; then
xcrun simctl boot "iPhone 16"
# Use whichever iPhone simulator ships with the current macos-latest image
# rather than pinning a device by name. GitHub periodically rolls
# macos-latest to a new Xcode image with a different default device set, so
# a hard-coded name (e.g. "iPhone 16") eventually stops resolving and the
# boot fails with "Invalid device or device pair". Prefer an already-booted
# device, otherwise take the first available iPhone.
DEVICE=$(xcrun simctl list devices available --json | jq -r '
[ .devices | to_entries[] | .key as $runtime | .value[]
| select(.name | startswith("iPhone"))
| { runtime: ($runtime | sub("com.apple.CoreSimulator.SimRuntime."; "")), name, udid, state } ]
| (map(select(.state == "Booted")) + .)
| .[0] // empty')

if [ -z "$DEVICE" ]; then
echo "No available iPhone simulator found on this runner" >&2
xcrun simctl list devices available
exit 1
fi
xcrun simctl bootstatus booted

UDID=$(echo "$DEVICE" | jq -r '.udid')
LABEL=$(echo "$DEVICE" | jq -r '"\(.name) [\(.runtime)] (\(.udid))"')

echo "Booting iOS simulator: $LABEL"
echo "iOS simulator: $LABEL" >> "$GITHUB_STEP_SUMMARY"

# -b boots the device if it isn't already, then waits for full boot.
xcrun simctl bootstatus "$UDID" -b
sleep 30

- name: Install app on simulator
Expand Down
4 changes: 4 additions & 0 deletions packages/common/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.6.2 ((7/8/2026, 07:16 AM PST))

This is an artificial version bump with no new change.

## 9.6.1 ((7/7/2026, 12:21 PM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/common/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-common",
"version": "9.6.1",
"version": "9.6.2",
"description": "Coinbase Design System - Common",
"repository": {
"type": "git",
Expand Down
4 changes: 4 additions & 0 deletions packages/mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.6.2 ((7/8/2026, 07:16 AM PST))

This is an artificial version bump with no new change.

## 9.6.1 ((7/7/2026, 12:21 PM PST))

This is an artificial version bump with no new change.
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mcp-server",
"version": "9.6.1",
"version": "9.6.2",
"description": "Coinbase Design System - MCP Server",
"repository": {
"type": "git",
Expand Down
6 changes: 6 additions & 0 deletions packages/mobile/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 9.6.2 (7/8/2026 PST)

#### 🐞 Fixes

- Chore: refactor from React.forwardRef to ref as prop for all cds-mobile components. [[#741](https://github.com/coinbase/cds/pull/741)]

## 9.6.1 (7/7/2026 PST)

#### 🐞 Fixes
Expand Down
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
2 changes: 1 addition & 1 deletion packages/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mobile",
"version": "9.6.1",
"version": "9.6.2",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
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 @@ -87,45 +87,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