From 6d64e4a12cc146e579ba478349dfb27767e1d9bd Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Thu, 19 Feb 2026 18:18:34 -0500 Subject: [PATCH 1/3] chore: update docs for MessagingCard --- .../cards/MessagingCard/_mobileExamples.mdx | 29 +++--- .../cards/MessagingCard/_webExamples.mdx | 33 ++++--- packages/mobile/src/cards/CardRoot.tsx | 2 +- .../__stories__/MessagingCard.stories.tsx | 92 ++++++++++++++++--- .../__stories__/MessagingCard.stories.tsx | 76 +++++++++++++++ 5 files changed, 197 insertions(+), 35 deletions(-) diff --git a/apps/docs/docs/components/cards/MessagingCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/MessagingCard/_mobileExamples.mdx index 70c7b2dec..544ffc5bf 100644 --- a/apps/docs/docs/components/cards/MessagingCard/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/MessagingCard/_mobileExamples.mdx @@ -75,7 +75,12 @@ Use `mediaPlacement` to control the position of media content. ## Upsell Card Styles -MessagingCard with `type="upsell"` supports various background colors to match different promotional purposes. Use the `background` prop for semantic tokens, or `dangerouslySetBackground` for custom spectrum colors. +MessagingCard with `type="upsell"` supports various background colors to match different promotional purposes. Use the `background` prop for semantic tokens. + +For **custom background colors**, use the recommended approach: + +- **Non-interactive cards** (`renderAsPressable={false}` or omitted): set the background via `styles.root` (e.g. `styles={{ root: { backgroundColor: 'rgb(...)' } }}`). +- **Interactive cards** (`renderAsPressable` with `onPress`): set the background via `blendStyles.background` (e.g. `blendStyles={{ background: 'rgb(...)' }}`) so press states are handled correctly. ### General Upsell @@ -140,7 +145,7 @@ function FeatureUpsell() { Up to 3.29% APR on ETH @@ -182,7 +187,7 @@ function CommunityUpsell() { Join the community @@ -244,7 +249,7 @@ function ProductUpsell() { {card.title} @@ -291,7 +296,7 @@ function NewsUpsell() { Help defend crypto in America @@ -366,7 +371,7 @@ function DismissibleCards() { console.log('Card pressed!')} type="upsell" - dangerouslySetBackground={`rgb(${spectrum.teal70})`} + blendStyles={{ background: `rgb(${spectrum.teal70})` }} title="Interactive Upsell" description="Tap to interact" width={320} @@ -745,7 +752,7 @@ function MultipleCards() { renderAsPressable onPress={() => console.log('clicked')} type="upsell" - dangerouslySetBackground={`rgb(${spectrum.purple70})`} + blendStyles={{ background: `rgb(${spectrum.purple70})` }} title="Card 3" description="Card with onPress handler" tag="Action" @@ -820,7 +827,7 @@ When you need both `onDismissButtonPress` and want the entire card to be pressab ### Color Contrast -MessagingCard supports custom backgrounds via `background` and `dangerouslySetBackground` props. When using custom background colors, ensure sufficient color contrast between text and background: +MessagingCard supports custom backgrounds via the `background` prop and, for custom colors, `styles.root` (non-interactive) or `blendStyles.background` (interactive). When using custom background colors, ensure sufficient color contrast between text and background: - Use `fgInverse` text color with dark backgrounds (e.g., `accentBoldPurple`, `bgInverse`) - Use `fg` text color with light backgrounds (e.g., `bgPrimaryWash`, `bgAlternate`) diff --git a/apps/docs/docs/components/cards/MessagingCard/_webExamples.mdx b/apps/docs/docs/components/cards/MessagingCard/_webExamples.mdx index 7bb204e61..a827cc7e7 100644 --- a/apps/docs/docs/components/cards/MessagingCard/_webExamples.mdx +++ b/apps/docs/docs/components/cards/MessagingCard/_webExamples.mdx @@ -75,7 +75,12 @@ Use `mediaPlacement` to control the position of media content. ## Upsell Card Styles -MessagingCard with `type="upsell"` supports various background colors to match different promotional purposes. Use the `background` prop for semantic tokens, or `dangerouslySetBackground` for custom spectrum colors. +MessagingCard with `type="upsell"` supports various background colors to match different promotional purposes. Use the `background` prop for semantic tokens. + +For **custom background colors**, use the recommended approach: + +- **Non-interactive cards** (default `as="article"` or `renderAsPressable={false}`): set the background via `styles.root` or `classNames.root` (e.g. `styles={{ root: { backgroundColor: 'rgb(var(--blue80))' } }}`). +- **Interactive cards** (`renderAsPressable` with `as="a"` or `as="button"`): set the background via `blendStyles.background` (e.g. `blendStyles={{ background: 'rgb(var(--blue80))' }}`) so press states are handled correctly. ### General Upsell @@ -130,7 +135,7 @@ function FeatureUpsell() { Up to 3.29% APR on ETH @@ -179,7 +184,7 @@ function CommunityUpsell() { Join the community @@ -240,7 +245,7 @@ function ProductUpsell() { {card.title} @@ -286,7 +291,7 @@ function NewsUpsell() { Help defend crypto in America @@ -373,7 +378,7 @@ Use `onDismissButtonClick` to add a dismiss button. mediaPlacement="end" onDismissButtonClick={() => alert('Card dismissed!')} dismissButtonAccessibilityLabel="Close card" - dangerouslySetBackground="rgb(var(--teal70))" + styles={{ root: { backgroundColor: 'rgb(var(--teal70))' } }} /> alert('Card clicked!')} type="upsell" - dangerouslySetBackground="rgb(var(--gray100))" + blendStyles={{ background: 'rgb(var(--gray100))' }} title="Interactive Card" description="Clickable card with onClick handler" width={320} @@ -771,7 +780,7 @@ Display multiple cards in a carousel. as="button" onClick={() => console.log('clicked')} type="upsell" - dangerouslySetBackground="rgb(var(--purple70))" + blendStyles={{ background: 'rgb(var(--purple70))' }} title="Card 3" description="Card with onClick handler" tag="Action" @@ -850,7 +859,7 @@ When you need both `onDismissButtonClick` and want the entire card to be clickab ### Color Contrast -MessagingCard supports custom backgrounds via `background` and `dangerouslySetBackground` props. When using custom background colors, ensure sufficient color contrast between text and background: +MessagingCard supports custom backgrounds via the `background` prop and, for custom colors, `styles.root` / `classNames.root` (non-interactive) or `blendStyles.background` (interactive). When using custom background colors, ensure sufficient color contrast between text and background: - Use `fgInverse` text color with dark backgrounds (e.g., `accentBoldPurple`, `bgInverse`) - Use `fg` text color with light backgrounds (e.g., `bgPrimaryWash`, `bgAlternate`) diff --git a/packages/mobile/src/cards/CardRoot.tsx b/packages/mobile/src/cards/CardRoot.tsx index 104d7eb4a..1009dac5e 100644 --- a/packages/mobile/src/cards/CardRoot.tsx +++ b/packages/mobile/src/cards/CardRoot.tsx @@ -27,7 +27,7 @@ export type CardRootProps = CardRootBaseProps & * When `renderAsPressable` is true, it renders as a Pressable component. */ export const CardRoot = memo( - forwardRef(({ children, style, renderAsPressable, ...props }, ref) => { + forwardRef(({ children, renderAsPressable, ...props }, ref) => { const Component = renderAsPressable ? Pressable : HStack; return ( diff --git a/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx b/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx index 2e7b647f0..be84c8fed 100644 --- a/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx @@ -78,7 +78,7 @@ const DismissibleCardsExample = () => { height={100} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={80} /> ) : ( @@ -125,7 +125,7 @@ const MessagingCardScreen = () => { height={120} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={90} /> } @@ -196,7 +196,7 @@ const MessagingCardScreen = () => { height={120} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={90} /> } @@ -224,7 +224,7 @@ const MessagingCardScreen = () => { height={120} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={90} /> } @@ -252,7 +252,7 @@ const MessagingCardScreen = () => { height={156} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={120} /> } @@ -282,7 +282,7 @@ const MessagingCardScreen = () => { height={186} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={130} /> } @@ -324,7 +324,7 @@ const MessagingCardScreen = () => { height={156} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={120} /> } @@ -373,7 +373,7 @@ const MessagingCardScreen = () => { height={120} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={90} /> } @@ -416,7 +416,7 @@ const MessagingCardScreen = () => { height={120} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={90} /> } @@ -438,6 +438,76 @@ const MessagingCardScreen = () => { + {/* Custom Background Color */} + + + + } + mediaPlacement="end" + onPress={NoopFn} + title="Pressable with Custom Background" + type="upsell" + /> + } + mediaPlacement="end" + onPress={NoopFn} + title="Nudge with Custom Background" + type="nudge" + /> + + } + mediaPlacement="end" + renderAsPressable={false} + styles={{ root: { backgroundColor: '#1E5A9E' } }} + title="Non-pressable with Custom Background" + type="upsell" + /> + } + mediaPlacement="end" + renderAsPressable={false} + styles={{ root: { backgroundColor: '#FFF8E6' } }} + title="Non-pressable Nudge with Custom Background" + type="nudge" + /> + + + {/* Text Content */} @@ -450,7 +520,7 @@ const MessagingCardScreen = () => { height={160} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={120} /> } @@ -471,7 +541,7 @@ const MessagingCardScreen = () => { height={140} resizeMode="cover" shape="rectangle" - source={{ uri: coinbaseOneLogo }} + source={coinbaseOneLogo} width={100} /> } diff --git a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx index 869922792..95b7bd02b 100644 --- a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx +++ b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx @@ -397,6 +397,82 @@ export const PolymorphicAndInteractive = (): JSX.Element => { ); }; +// Custom Background Color (use styles.root for non-interactive, blendStyles.background for interactive) +export const CustomBackgroundColor = (): JSX.Element => { + return ( + + + } + mediaPlacement="end" + onClick={() => alert('Card clicked!')} + title="Pressable with Custom Background" + type="upsell" + width={320} + /> + } + mediaPlacement="end" + target="_blank" + title="Link with Custom Background" + type="nudge" + width={320} + /> + + } + mediaPlacement="end" + renderAsPressable={false} + styles={{ root: { backgroundColor: 'rgb(var(--blue80))' } }} + title="Non-pressable with Custom Background" + type="upsell" + width={320} + /> + } + mediaPlacement="end" + renderAsPressable={false} + styles={{ root: { backgroundColor: 'rgb(var(--yellow20))' } }} + title="Non-pressable Nudge with Custom Background" + type="nudge" + width={320} + /> + + ); +}; + // Text Content export const TextContent = (): JSX.Element => { return ( From a759e1f7214ad04542b1340270c95c8b6bd6ed59 Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Thu, 19 Feb 2026 18:18:54 -0500 Subject: [PATCH 2/3] update changelogs --- packages/common/CHANGELOG.md | 4 ++++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 ++++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 6 ++++++ packages/mobile/package.json | 2 +- packages/web/CHANGELOG.md | 4 ++++ packages/web/package.json | 2 +- 8 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index d147ed577..ccade5c2f 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.47.2 ((2/19/2026, 03:18 PM PST)) + +This is an artificial version bump with no new change. + ## 8.47.1 ((2/19/2026, 01:18 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index 59ae5d995..9a016cc3c 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.47.1", + "version": "8.47.2", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 5c16ee14f..7295b1760 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.47.2 ((2/19/2026, 03:18 PM PST)) + +This is an artificial version bump with no new change. + ## 8.47.1 ((2/19/2026, 01:18 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 4d4305099..5ea99e2fc 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.47.1", + "version": "8.47.2", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 68d4a6b00..d6b320056 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 8.47.2 (2/19/2026 PST) + +#### 🐞 Fixes + +- Fix mobile CardRoot style forwarding logic. [[#405](https://github.com/coinbase/cds/pull/405)] + ## 8.47.1 (2/19/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile/package.json b/packages/mobile/package.json index a32e5ae86..7f4cec0b3 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.47.1", + "version": "8.47.2", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index af54c22cb..ed7b6ad17 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 8.47.2 ((2/19/2026, 03:18 PM PST)) + +This is an artificial version bump with no new change. + ## 8.47.1 ((2/19/2026, 01:18 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/web/package.json b/packages/web/package.json index 60916e957..4961adf45 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.47.1", + "version": "8.47.2", "description": "Coinbase Design System - Web", "repository": { "type": "git", From aaf3e63c8eb94fa0d12c5c7a10a15b9bbb168337 Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Thu, 19 Feb 2026 14:10:27 -0500 Subject: [PATCH 3/3] changes --- .../test-no-dangerously-set-background.tsx | 24 +++ eslint.config.mjs | 11 ++ package.json | 1 + packages/eslint-plugin-cds/CHANGELOG.md | 6 + packages/eslint-plugin-cds/README.md | 10 +- packages/eslint-plugin-cds/package.json | 2 +- .../eslint-plugin-cds/src/configs/mobile.ts | 2 + packages/eslint-plugin-cds/src/configs/web.ts | 2 + packages/eslint-plugin-cds/src/rules.ts | 2 + .../rules/no-dangerously-set-background.ts | 156 ++++++++++++++++++ .../no-dangerously-set-background.test.ts | 97 +++++++++++ yarn.lock | 3 +- 12 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 apps/docs/src/test-no-dangerously-set-background.tsx create mode 100644 packages/eslint-plugin-cds/src/rules/no-dangerously-set-background.ts create mode 100644 packages/eslint-plugin-cds/tests/no-dangerously-set-background.test.ts diff --git a/apps/docs/src/test-no-dangerously-set-background.tsx b/apps/docs/src/test-no-dangerously-set-background.tsx new file mode 100644 index 000000000..3fe31770c --- /dev/null +++ b/apps/docs/src/test-no-dangerously-set-background.tsx @@ -0,0 +1,24 @@ +/** + * Temporary file to test @coinbase/cds/no-dangerously-set-background in the docs app. + * Run: yarn eslint apps/docs/src/test-no-dangerously-set-background.tsx + * You should see a warning on the Button line. Delete this file when done. + */ +import { Button } from '@coinbase/cds-web/buttons'; +import { MessagingCard as MessagingCardComponent } from '@coinbase/cds-web/cards/MessagingCard'; +import { Interactable } from '@coinbase/cds-web/system/Interactable'; + +export function TestComponent() { + return ( + <> + This should trigger the rule + + This should trigger the rule + + + ); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 3962a700d..440449a5a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ import eslintReactNativeA11y from 'eslint-plugin-react-native-a11y'; import eslintReactNative from 'eslint-plugin-react-native'; import eslintCodegen from 'eslint-plugin-codegen'; import internalPlugin from '@coinbase/eslint-plugin-internal'; +import cdsPlugin from '@coinbase/eslint-plugin-cds'; const ignores = [ '*.md', @@ -286,6 +287,16 @@ export default tseslint.config( ...packageProductionRules, }, }, + // CDS rule for docs app (no-dangerously-set-background) + { + files: ['apps/docs/**/*.{ts,tsx}'], + plugins: { + '@coinbase/cds': cdsPlugin, + }, + rules: { + '@coinbase/cds/no-dangerously-set-background': 'warn', + }, + }, { files: ['**/*mobile*/**/*.{ts,tsx}'], settings: sharedSettings, diff --git a/package.json b/package.json index c998c7338..382c40de0 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@babel/runtime": "^7.28.2", "@babel/template": "^7.20.7", "@babel/types": "^7.20.7", + "@coinbase/eslint-plugin-cds": "workspace:^", "@coinbase/eslint-plugin-internal": "workspace:^", "@figma/code-connect": "^1.3.13", "@graphql-tools/jest-transform": "^2.0.0", diff --git a/packages/eslint-plugin-cds/CHANGELOG.md b/packages/eslint-plugin-cds/CHANGELOG.md index cf9ea664f..d0d90f569 100644 --- a/packages/eslint-plugin-cds/CHANGELOG.md +++ b/packages/eslint-plugin-cds/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 3.3.0 (2/19/2026 PST) + +#### 🚀 Updates + +- Add lint rule to avoid dangerouslySetBackground on Interactable. [[#405](https://github.com/coinbase/cds/pull/405)] + ## 3.2.1 (10/1/2025 PST) #### 🐞 Fixes diff --git a/packages/eslint-plugin-cds/README.md b/packages/eslint-plugin-cds/README.md index 1bc4cdf53..98046cb40 100644 --- a/packages/eslint-plugin-cds/README.md +++ b/packages/eslint-plugin-cds/README.md @@ -199,7 +199,15 @@ This rule also checks for other required a11y labels that need to be enforced ou ### Current CDS Best Practices Rules -TBD +#### no-dangerously-set-background (Web & Mobile) + +**Rule Description**: + +The `no-dangerously-set-background` rule warns when the `dangerouslySetBackground` prop is used on **Interactable**, **Pressable**, or **Card components** (MessagingCard, MediaCard, DataCard) when the card is interactive. For those card components, the rule only runs when `renderAsPressable` is explicitly set to `true` (or the shorthand `renderAsPressable`). Cards without `renderAsPressable` or with `renderAsPressable={false}` are ignored. Other components (e.g. Box, UpsellCard, CardRoot) are ignored. + +**Why**: Background color applied via `dangerouslySetBackground` is not picked up by the color blending logic. As a result, the interactable does not display the correct background in its hovered, pressed, and disabled states. Use `blendStyles.background` so the blending logic applies and these states render correctly. + +The rule is enabled by default in both `configs.web` and `configs.mobile` (and legacy configs) as `warn`. ## Development diff --git a/packages/eslint-plugin-cds/package.json b/packages/eslint-plugin-cds/package.json index 3d3e190e8..f5cfdd078 100644 --- a/packages/eslint-plugin-cds/package.json +++ b/packages/eslint-plugin-cds/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/eslint-plugin-cds", - "version": "3.2.1", + "version": "3.3.0", "description": "ESLint plugin for CDS", "repository": { "type": "git", diff --git a/packages/eslint-plugin-cds/src/configs/mobile.ts b/packages/eslint-plugin-cds/src/configs/mobile.ts index e09ef3e05..5f9c98ccd 100644 --- a/packages/eslint-plugin-cds/src/configs/mobile.ts +++ b/packages/eslint-plugin-cds/src/configs/mobile.ts @@ -23,6 +23,7 @@ export function buildMobileConfig(plugin: Record) { rules: { 'react-native-a11y/has-accessibility-hint': 'off', '@coinbase/cds/has-valid-accessibility-descriptors-extended': 'warn', + '@coinbase/cds/no-dangerously-set-background': 'warn', }, }; } @@ -32,5 +33,6 @@ export const legacyMobileConfig = { rules: { 'react-native-a11y/has-accessibility-hint': 'off', '@coinbase/cds/has-valid-accessibility-descriptors-extended': 'warn', + '@coinbase/cds/no-dangerously-set-background': 'warn', }, }; diff --git a/packages/eslint-plugin-cds/src/configs/web.ts b/packages/eslint-plugin-cds/src/configs/web.ts index 411facf7b..1ef69d855 100644 --- a/packages/eslint-plugin-cds/src/configs/web.ts +++ b/packages/eslint-plugin-cds/src/configs/web.ts @@ -22,6 +22,7 @@ export function buildWebConfig(plugin: Record) { }, rules: { '@coinbase/cds/control-has-associated-label-extended': 'warn', + '@coinbase/cds/no-dangerously-set-background': 'warn', '@coinbase/cds/no-v7-imports': 'warn', 'jsx-a11y/control-has-associated-label': [ 'warn', @@ -48,6 +49,7 @@ export const legacyWebConfig = { plugins: ['jsx-a11y'], rules: { '@coinbase/cds/control-has-associated-label-extended': 'warn', + '@coinbase/cds/no-dangerously-set-background': 'warn', '@coinbase/cds/no-v7-imports': 'warn', }, overrides: [ diff --git a/packages/eslint-plugin-cds/src/rules.ts b/packages/eslint-plugin-cds/src/rules.ts index 68cc38415..26eb8c10c 100644 --- a/packages/eslint-plugin-cds/src/rules.ts +++ b/packages/eslint-plugin-cds/src/rules.ts @@ -2,11 +2,13 @@ import type { TSESLint } from '@typescript-eslint/utils'; import { controlHasAssociatedLabelExtended } from './rules/control-has-associated-label-extended'; import { hasValidA11yDescriptorsExtended } from './rules/has-valid-accessibility-descriptors-extended'; +import { noDangerouslySetBackground } from './rules/no-dangerously-set-background'; import { noV7Imports } from './rules/no-v7-imports'; export const rules = { 'control-has-associated-label-extended': controlHasAssociatedLabelExtended, 'has-valid-accessibility-descriptors-extended': hasValidA11yDescriptorsExtended, + 'no-dangerously-set-background': noDangerouslySetBackground, 'no-v7-imports': noV7Imports, } as const satisfies { [key: string]: TSESLint.RuleModule; diff --git a/packages/eslint-plugin-cds/src/rules/no-dangerously-set-background.ts b/packages/eslint-plugin-cds/src/rules/no-dangerously-set-background.ts new file mode 100644 index 000000000..f39e7692f --- /dev/null +++ b/packages/eslint-plugin-cds/src/rules/no-dangerously-set-background.ts @@ -0,0 +1,156 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; + +export const RULE_NAME = 'no-dangerously-set-background'; + +type MessageIds = 'usePreferredApi'; + +const CDS_PACKAGE_PREFIXES = ['@coinbase/cds-web', '@coinbase/cds-mobile']; + +const INTERACTABLE_PRESSABLE_OR_BUTTON = new Set(['Interactable', 'Pressable', 'Button']); +const CARD_COMPONENTS = new Set(['MessagingCard', 'MediaCard', 'DataCard']); + +function getComponentName(nameNode: TSESTree.JSXTagNameExpression): string | null { + if (nameNode.type === 'JSXIdentifier') { + return nameNode.name; + } + if (nameNode.type === 'JSXMemberExpression') { + const property = nameNode.property.type === 'JSXIdentifier' ? nameNode.property.name : null; + return property ?? null; + } + return null; +} + +function getImportLocalName(nameNode: TSESTree.JSXTagNameExpression): string | null { + if (nameNode.type === 'JSXIdentifier') { + return nameNode.name; + } + if (nameNode.type === 'JSXMemberExpression') { + return nameNode.object.type === 'JSXIdentifier' ? nameNode.object.name : null; + } + return null; +} + +function isCdsImportSource(source: string): boolean { + return CDS_PACKAGE_PREFIXES.some( + (prefix) => source === prefix || source.startsWith(`${prefix}/`), + ); +} + +function hasRenderAsPressableTrue(attributes: TSESTree.JSXAttribute[]): boolean { + const attr = attributes.find( + (a) => + a.type === 'JSXAttribute' && + a.name.type === 'JSXIdentifier' && + a.name.name === 'renderAsPressable', + ); + if (!attr) { + return false; + } + if (!attr.value) { + return true; + } + if (attr.value.type !== 'JSXExpressionContainer') { + return false; + } + const expr = attr.value.expression; + return expr.type === 'Literal' && expr.value === true; +} + +/** + * Warns when dangerouslySetBackground is used on Interactable, Pressable, Button, or + * Card components (MessagingCard, MediaCard, DataCard with renderAsPressable true). Use blendStyles.background + * so the interactable displays the correct color in hovered, pressed, and + * disabled states. + */ +export const noDangerouslySetBackground: TSESLint.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow dangerouslySetBackground on Interactable, Pressable, Button, and Card components (MessagingCard, MediaCard, DataCard when renderAsPressable is true). Use blendStyles.background so the interactable displays the correct color in hovered, pressed, and disabled states.', + }, + messages: { + usePreferredApi: + 'Use blendStyles.background instead so the interactable displays the correct color in hovered, pressed, and disabled states.', + }, + schema: [], + }, + defaultOptions: [], + create(context: TSESLint.RuleContext) { + const importedFromCds: Record = {}; + const canonicalNameByLocalName: Record = {}; + + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + const source = typeof node.source.value === 'string' ? node.source.value : null; + if (source === null || !isCdsImportSource(source)) { + return; + } + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ImportSpecifier' || + specifier.type === 'ImportDefaultSpecifier' || + specifier.type === 'ImportNamespaceSpecifier' + ) { + const localName = specifier.local.name; + importedFromCds[localName] = source; + canonicalNameByLocalName[localName] = + specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier' + ? specifier.imported.name + : localName; + } + } + }, + JSXOpeningElement(node: TSESTree.JSXOpeningElement) { + const dangerouslySetBackgroundAttr = node.attributes.find( + (attr): attr is TSESTree.JSXAttribute => + attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'dangerouslySetBackground', + ); + + if (!dangerouslySetBackgroundAttr) { + return; + } + + const importLocalName = getImportLocalName(node.name); + if (importLocalName === null) { + return; + } + + const source = importedFromCds[importLocalName]; + if (!source || !isCdsImportSource(source)) { + return; + } + + const canonicalName = canonicalNameByLocalName[importLocalName] ?? importLocalName; + const nameForSetCheck = + node.name.type === 'JSXMemberExpression' ? getComponentName(node.name) : canonicalName; + if (nameForSetCheck === null) { + return; + } + + if (INTERACTABLE_PRESSABLE_OR_BUTTON.has(nameForSetCheck)) { + context.report({ + node: dangerouslySetBackgroundAttr, + messageId: 'usePreferredApi', + }); + return; + } + + if (CARD_COMPONENTS.has(nameForSetCheck)) { + const jsxAttrs = node.attributes.filter( + (a): a is TSESTree.JSXAttribute => a.type === 'JSXAttribute', + ); + if (!hasRenderAsPressableTrue(jsxAttrs)) { + return; + } + context.report({ + node: dangerouslySetBackgroundAttr, + messageId: 'usePreferredApi', + }); + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-cds/tests/no-dangerously-set-background.test.ts b/packages/eslint-plugin-cds/tests/no-dangerously-set-background.test.ts new file mode 100644 index 000000000..3385bde04 --- /dev/null +++ b/packages/eslint-plugin-cds/tests/no-dangerously-set-background.test.ts @@ -0,0 +1,97 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { noDangerouslySetBackground } from '../src/rules/no-dangerously-set-background'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +ruleTester.run( + 'no-dangerously-set-background', + noDangerouslySetBackground as unknown as Parameters[1], + { + valid: [ + { + code: '', + }, + { + code: '', + }, + { + code: '', + }, + { + code: '', + }, + { + code: '', + }, + { + code: '', + }, + { + code: '', + }, + { + code: '', + }, + { + code: '...', + }, + { + code: '', + }, + { + code: 'import { Button } from \'./my-button\'; const x = ;', + }, + ], + invalid: [ + { + code: 'import { Button as SubmitButton } from \'@coinbase/cds-web\'; const x = Submit;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { Interactable } from \'@coinbase/cds-web\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { Pressable } from \'@coinbase/cds-web\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { Button } from \'@coinbase/cds-web\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { MessagingCard } from \'@coinbase/cds-web\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { MessagingCard } from \'@coinbase/cds-web\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { MediaCard } from \'@coinbase/cds-web\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { DataCard } from \'@coinbase/cds-web\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { Button } from \'@coinbase/cds-mobile\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + { + code: 'import { MessagingCard } from \'@coinbase/cds-web/cards\'; const x = ;', + errors: [{ messageId: 'usePreferredApi' }], + }, + ], + }, +); diff --git a/yarn.lock b/yarn.lock index 90d258313..f6f4e4fc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2708,7 +2708,7 @@ __metadata: languageName: unknown linkType: soft -"@coinbase/eslint-plugin-cds@workspace:packages/eslint-plugin-cds": +"@coinbase/eslint-plugin-cds@workspace:^, @coinbase/eslint-plugin-cds@workspace:packages/eslint-plugin-cds": version: 0.0.0-use.local resolution: "@coinbase/eslint-plugin-cds@workspace:packages/eslint-plugin-cds" dependencies: @@ -17655,6 +17655,7 @@ __metadata: "@babel/runtime": "npm:^7.28.2" "@babel/template": "npm:^7.20.7" "@babel/types": "npm:^7.20.7" + "@coinbase/eslint-plugin-cds": "workspace:^" "@coinbase/eslint-plugin-internal": "workspace:^" "@figma/code-connect": "npm:^1.3.13" "@graphql-tools/jest-transform": "npm:^2.0.0"