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
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.23.0 ((8/26/2026, 09:18 AM PST))

This is an artificial version bump with no new change.

## 9.22.0 ((8/25/2026, 08:11 AM 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.22.0",
"version": "9.23.0",
"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.23.0 ((8/26/2026, 09:18 AM PST))

This is an artificial version bump with no new change.

## 9.22.0 ((8/25/2026, 08:11 AM 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.22.0",
"version": "9.23.0",
"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.23.0 (8/26/2026 PST)

#### 🚀 Updates

- Let TextIcon respect IconGlyphSourceContext overrides. [[#862](https://github.com/coinbase/cds/pull/862)]

## 9.22.0 (8/25/2026 PST)

#### 🚀 Updates
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.22.0",
"version": "9.23.0",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
Expand Down
27 changes: 17 additions & 10 deletions packages/mobile/src/icons/TextIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { isDevelopment } from '@coinbase/cds-utils';
import { useTheme } from '../hooks/useTheme';

import type { IconProps } from './Icon';
import { getIconSourceSize } from './Icon';
import { DEFAULT_ICON_FONT_FAMILY, getIconSourceSize, useResolvedGlyph } from './createIcon';

export type TextIconProps = Pick<IconProps, 'color' | 'size' | 'testID'> & {
name: IconName;
Expand All @@ -23,6 +23,10 @@ export type TextIconProps = Pick<IconProps, 'color' | 'size' | 'testID'> & {
style?: StyleProp<TextStyle>;
}
);

/** Stable bound source so TextIcon participates in the same context resolution as Icon. */
const cdsGlyphSource = { glyphMap, fontFamily: DEFAULT_ICON_FONT_FAMILY };

/**
*
* This is a simplified, text-only version of the Icon component.
Expand All @@ -41,36 +45,39 @@ export const TextIcon = memo(function TextIcon({
const theme = useTheme();
const Component = animated ? Animated.Text : Text;
const iconSize = theme.iconSize[size];
const sourceSize = getIconSourceSize(iconSize);
const iconColor = theme.color[color];

const resolved = useResolvedGlyph(cdsGlyphSource, {
name,
size,
pixelSize: iconSize,
active: Boolean(active),
});

const styles = useMemo(
() =>
[
{
fontFamily: 'CoinbaseIcons',
fontFamily: resolved?.fontFamily,
fontSize: iconSize,
color: iconColor,
},
style,
// TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern
] as StyleProp<TextStyle>,
[style, iconColor, iconSize],
[style, iconColor, iconSize, resolved?.fontFamily],
);

const iconName = `${name}-${sourceSize}-${active ? 'active' : 'inactive'}`;
const glyph = glyphMap[iconName as keyof typeof glyphMap];

if (glyph === undefined) {
if (resolved === undefined) {
if (isDevelopment()) {
console.error(`Unable to find glyph for icon name "${name}" with glyph key "${iconName}"`);
console.error(`Unable to find glyph for icon name "${name}" at size "${size}"`);
}
return null;
}

return (
<Component accessibilityRole="image" style={styles} testID={testID}>
{glyph}
{resolved.char}
</Component>
);
});
97 changes: 97 additions & 0 deletions packages/mobile/src/icons/__tests__/TextIcon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { IconName } from '@coinbase/cds-common/types/IconName';
import { render, screen } from '@testing-library/react-native';

import { DefaultThemeProvider } from '../../utils/testHelpers';
import { DEFAULT_ICON_FONT_FAMILY, type GlyphMap, IconGlyphSourceProvider } from '../createIcon';
import { TextIcon } from '../TextIcon';

const INACTIVE_GLYPH = '\u2606'; // ☆
const ACTIVE_GLYPH = '\u2605'; // ★
const OTHER_GLYPH = '\u25B2'; // ▲

type DemoIconName = 'star';

const demoGlyphMap: GlyphMap<DemoIconName> = {
'star-12-active': ACTIVE_GLYPH,
'star-12-inactive': INACTIVE_GLYPH,
'star-16-active': ACTIVE_GLYPH,
'star-16-inactive': INACTIVE_GLYPH,
'star-24-active': ACTIVE_GLYPH,
'star-24-inactive': INACTIVE_GLYPH,
};

// TextIcon reads directly from the CDS glyphMap module; mock it so tests are
// independent of the published icon set.
jest.mock('@coinbase/cds-icons/glyphMap', () => ({
glyphMap: {
'star-12-active': '\u2605',
'star-12-inactive': '\u2606',
'star-16-active': '\u2605',
'star-16-inactive': '\u2606',
'star-24-active': '\u2605',
'star-24-inactive': '\u2606',
},
}));

const renderTextIcon = (ui: React.ReactElement) =>
render(<DefaultThemeProvider>{ui}</DefaultThemeProvider>);

describe('TextIcon', () => {
it('renders the CDS glyph by default', () => {
renderTextIcon(<TextIcon name={'star' as DemoIconName & string} />);
expect(screen.getByText(INACTIVE_GLYPH)).toBeTruthy();
});

it('uses the default CDS font family', () => {
renderTextIcon(<TextIcon name={'star' as DemoIconName & string} />);
expect(screen.getByText(INACTIVE_GLYPH)).toHaveStyle({ fontFamily: DEFAULT_ICON_FONT_FAMILY });
});

it('uses the glyph and font family from the context source when the name matches', () => {
renderTextIcon(
<IconGlyphSourceProvider
source={{ glyphMap: { 'star-24-inactive': OTHER_GLYPH }, fontFamily: 'RetailIcons' }}
>
<TextIcon name={'star' as DemoIconName & string} />
</IconGlyphSourceProvider>,
);

expect(screen.getByText(OTHER_GLYPH)).toHaveStyle({ fontFamily: 'RetailIcons' });
expect(screen.queryByText(INACTIVE_GLYPH)).toBeNull();
});

it('falls back to the CDS glyphMap when the context source does not cover the name', () => {
renderTextIcon(
<IconGlyphSourceProvider
source={{ glyphMap: { 'triangle-24-inactive': OTHER_GLYPH }, fontFamily: 'RetailIcons' }}
>
<TextIcon name={'star' as DemoIconName & string} />
</IconGlyphSourceProvider>,
);

expect(screen.getByText(INACTIVE_GLYPH)).toHaveStyle({
fontFamily: DEFAULT_ICON_FONT_FAMILY,
});
});

it('uses a custom getGlyph resolver from the context source', () => {
const getGlyph = jest.fn(() => OTHER_GLYPH);
renderTextIcon(
<IconGlyphSourceProvider
source={{ glyphMap: demoGlyphMap, getGlyph, fontFamily: 'CustomFont' }}
>
<TextIcon name={'star' as DemoIconName & string} />
</IconGlyphSourceProvider>,
);

expect(screen.getByText(OTHER_GLYPH)).toHaveStyle({ fontFamily: 'CustomFont' });
expect(getGlyph).toHaveBeenCalledWith(expect.objectContaining({ name: 'star', active: false }));
});

it('returns null when no glyph is found in either the context source or CDS glyphMap', () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
renderTextIcon(<TextIcon name={'missing' as unknown as IconName} />);
expect(screen.queryByRole('image')).toBeNull();
consoleError.mockRestore();
});
});
18 changes: 15 additions & 3 deletions packages/mobile/src/icons/createIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export type IconGlyphSource<Name extends string = string> = {
getGlyph?: (args: IconGlyphResolverArgs<Name>) => string | undefined;
};

const IconGlyphSourceContext = createContext<IconGlyphSource<any> | undefined>(undefined);
export const IconGlyphSourceContext = createContext<IconGlyphSource<any> | undefined>(undefined);

export type IconGlyphSourceProviderProps = {
/**
Expand Down Expand Up @@ -161,6 +161,19 @@ const resolveGlyph = (
return fromContext ?? resolveFromSource(boundSource, args);
};

/**
* Resolves a glyph for `name` against the nearest `IconGlyphSourceProvider`,
* falling back to `boundSource` when the context has no match.
* Extracts the shared lookup logic so both `createIcon` and `TextIcon` use it.
*/
export function useResolvedGlyph(
boundSource: IconGlyphSource<any>,
args: Omit<IconGlyphResolverArgs<string>, 'glyphMap'>,
): ResolvedGlyph | undefined {
const contextSource = useContext(IconGlyphSourceContext);
return resolveGlyph(contextSource, boundSource, args);
}

/** Creates a typed `Icon` component bound to an icon set. */
export function createIcon<Name extends string>(source: IconGlyphSource<Name>) {
const Icon = memo(({ ref, ..._props }: IconProps<Name> & { ref?: React.Ref<Text> }) => {
Expand Down Expand Up @@ -197,8 +210,7 @@ export function createIcon<Name extends string>(source: IconGlyphSource<Name>) {
const finalColor = dangerouslySetColor ?? iconColor;

// Tried before the bound set, so a source can override a built-in icon.
const contextSource = useContext(IconGlyphSourceContext);
const resolved = resolveGlyph(contextSource, source, {
const resolved = useResolvedGlyph(source, {
name,
size,
pixelSize: iconSize,
Expand Down
4 changes: 4 additions & 0 deletions packages/web/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.23.0 ((8/26/2026, 09:18 AM PST))

This is an artificial version bump with no new change.

## 9.22.0 (8/25/2026 PST)

#### 🚀 Updates
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web",
"version": "9.22.0",
"version": "9.23.0",
"description": "Coinbase Design System - Web",
"repository": {
"type": "git",
Expand Down
Loading