diff --git a/apps/docs/docs/components/charts/Legend/_webExamples.mdx b/apps/docs/docs/components/charts/Legend/_webExamples.mdx index def80130fa..ce6b3e867a 100644 --- a/apps/docs/docs/components/charts/Legend/_webExamples.mdx +++ b/apps/docs/docs/components/charts/Legend/_webExamples.mdx @@ -710,7 +710,7 @@ function InteractiveLegend() { compact aria-label={`${isEmphasized ? 'Remove emphasis from' : 'Emphasize'} ${label} series`} aria-pressed={isEmphasized} - inverted={isEmphasized} + active={isEmphasized} onClick={() => handleToggle(seriesId)} style={{ backgroundColor: `rgb(var(${baseColor}10))`, diff --git a/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx b/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx index c956da4432..f12f97df15 100644 --- a/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx @@ -50,21 +50,41 @@ function Example() { ## Styling -### Color +### Active Color -Use `invertColorScheme` to invert foreground and background for emphasis. +Use `active` for high-contrast emphasis. When `active` is true, the Chip inverts the color scheme for everything inside its borders — `background`, `color`, icons, and other token-based colors are all resolved against the opposite light/dark palette. Set `background` and `color` to control the inactive appearance; those same tokens flip automatically when active. ```tsx function Example() { return ( Default - Inverted + Active + + Custom tokens + ); } ``` +To break from the inverted convention, set `activeBackground` and/or `activeColor` while `active` is true. The Chip paints those tokens directly instead of inverting. **Any `start`, `end`, or `children` ReactNodes need explicit `color` props** — they are not updated automatically. + +```tsx +function Example() { + return ( + } + > + Positive active + + ); +} +``` + ### Sizes `size` accepts `xs` and `s`. Defaults to `s`. diff --git a/apps/docs/docs/components/inputs/Chip/_webExamples.mdx b/apps/docs/docs/components/inputs/Chip/_webExamples.mdx index 79748c0252..77f687e616 100644 --- a/apps/docs/docs/components/inputs/Chip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Chip/_webExamples.mdx @@ -50,21 +50,41 @@ function Example() { ## Styling -### Color +### Active Color -Use `invertColorScheme` to invert foreground and background for emphasis. +Use `active` for high-contrast emphasis. When `active` is true, the Chip inverts the color scheme for everything inside its borders — `background`, `color`, icons, and other token-based colors are all resolved against the opposite light/dark palette. Set `background` and `color` to control the inactive appearance; those same tokens flip automatically when active. ```tsx live function Example() { return ( Default - Inverted + Active + + Custom tokens + ); } ``` +To break from the inverted convention, set `activeBackground` and/or `activeColor` while `active` is true. The Chip paints those tokens directly instead of inverting. **Any `start`, `end`, or `children` ReactNodes need explicit `color` props** — they are not updated automatically. + +```tsx live +function Example() { + return ( + } + > + Positive active + + ); +} +``` + ### Sizes `size` accepts `xs` and `s`. Defaults to `s`. diff --git a/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx b/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx index 84ba7ff5f2..b6bddab821 100644 --- a/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx @@ -79,20 +79,16 @@ function Example() { ``` -### Invert color scheme +### Active -Use `invertColorScheme` to emphasize removable values. +InputChip is active by default. Set `active={false}` for a less emphasized appearance. ```tsx function Example() { return ( - console.log('Remove Default')} value="Default" /> - console.log('Remove Inverted')} - value="Inverted" - /> + console.log('Remove Active')} value="Active (default)" /> + console.log('Remove Inactive')} value="Inactive" /> ); } diff --git a/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx b/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx index 23f5a09924..4f93dbc634 100644 --- a/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx @@ -86,20 +86,16 @@ function Example() { } ``` -### Invert color scheme +### Active -Use `invertColorScheme` to emphasize removable values. +InputChip is active by default. Set `active={false}` for a less emphasized appearance. ```tsx live function Example() { return ( - console.log('Remove Default')} value="Default" /> - console.log('Remove Inverted')} - value="Inverted" - /> + console.log('Remove Active')} value="Active (default)" /> + console.log('Remove Inactive')} value="Inactive" /> ); } diff --git a/apps/docs/docs/components/inputs/MediaChip/_mobileExamples.mdx b/apps/docs/docs/components/inputs/MediaChip/_mobileExamples.mdx index 88abca1c95..cf36e9a3c7 100644 --- a/apps/docs/docs/components/inputs/MediaChip/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/MediaChip/_mobileExamples.mdx @@ -76,15 +76,15 @@ MediaChip supports all six content combinations automatically. ``` -### Invert color scheme +### Active -Use `invertColorScheme` to emphasize the chip with inverted colors. +Use `active` to emphasize the chip with high-contrast colors. ```tsx - Selected + Selected } start={} > diff --git a/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx b/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx index 489edd08ff..1d26b3ecc9 100644 --- a/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx @@ -84,15 +84,15 @@ MediaChip supports all six content combinations automatically. ``` -### Invert color scheme +### Active -Use `invertColorScheme` to emphasize the chip with inverted colors. +Use `active` to emphasize the chip with high-contrast colors. ```tsx live - Selected + Selected } start={} > diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/_mobileExamples.mdx b/apps/docs/docs/components/inputs/SelectChipAlpha/_mobileExamples.mdx index c08c7edaf0..ac68a076b3 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/_mobileExamples.mdx @@ -4,6 +4,12 @@ SelectChip is a chip-styled control built on top of the [Alpha Select](/componen Avoid using options with duplicate values. Each option's `value` should be unique within the options array to ensure proper selection behavior. ::: +## Active state + +SelectChip uses the same `active` semantics as [Chip](/components/inputs/Chip/): when `active` is true, the control inverts the color scheme for everything inside the chip borders. + +By default, SelectChip sets `active` to `true` whenever one or more values are selected and `false` when the control is empty. You do not need to wire `active` to `value` manually. Pass `active` explicitly to force the emphasized state even when nothing is selected, or set `active={false}` to keep the chip visually inactive despite a selection. + ## Basics ### Basic usage @@ -256,32 +262,6 @@ function ExampleSizes() { } ``` -### Inverted - -```tsx -function ExampleInverted() { - const exampleOptions = [ - { value: null, label: 'Clear selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - ]; - const [value, setValue] = useState(null); - const hasValue = value !== null; - return ( - - ); -} -``` - ### Start and end nodes ```tsx diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/_webExamples.mdx b/apps/docs/docs/components/inputs/SelectChipAlpha/_webExamples.mdx index 246be706a2..4a7018fc6a 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/_webExamples.mdx @@ -4,6 +4,12 @@ SelectChip is a chip-styled control built on top of the [Alpha Select](/componen Avoid using options with duplicate values. Each option's `value` should be unique within the options array to ensure proper selection behavior. ::: +## Active state + +SelectChip uses the same `active` semantics as [Chip](/components/inputs/Chip/): when `active` is true, the control inverts the color scheme for everything inside the chip borders. + +By default, SelectChip sets `active` to `true` whenever one or more values are selected and `false` when the control is empty. You do not need to wire `active` to `value` manually. Pass `active` explicitly to force the emphasized state even when nothing is selected, or set `active={false}` to keep the chip visually inactive despite a selection. + ## Basics ### Basic usage @@ -277,32 +283,6 @@ function ExampleSizes() { } ``` -### Inverted - -```jsx live -function ExampleInverted() { - const exampleOptions = [ - { value: null, label: 'Clear selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - ]; - const [value, setValue] = useState(null); - const hasValue = value !== null; - return ( - - ); -} -``` - ### Start and end nodes ```jsx live diff --git a/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx b/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx index 9f704e3c4c..641b80ceca 100644 --- a/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx +++ b/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx @@ -4,6 +4,11 @@ import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import { Accordion } from '@coinbase/cds-mobile/accordion/Accordion'; import { AccordionItem } from '@coinbase/cds-mobile/accordion/AccordionItem'; import { Select } from '@coinbase/cds-mobile/alpha/select/Select'; +import { SelectChip } from '@coinbase/cds-mobile/alpha/select-chip/SelectChip'; +import { TabbedChips } from '@coinbase/cds-mobile/alpha/tabbed-chips/TabbedChips'; +import { InputChip } from '@coinbase/cds-mobile/chips/InputChip'; +import { CheckboxCell } from '@coinbase/cds-mobile/controls/CheckboxCell'; +import { RadioCell } from '@coinbase/cds-mobile/controls/RadioCell'; import { Switch } from '@coinbase/cds-mobile/controls/Switch'; import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'; import { DateInput } from '@coinbase/cds-mobile/dates/DateInput'; @@ -18,10 +23,13 @@ import { SegmentedTabs } from '@coinbase/cds-mobile/tabs/SegmentedTabs'; import { Tabs } from '@coinbase/cds-mobile/tabs/Tabs'; import { Text } from '@coinbase/cds-mobile/typography/Text'; +import type { ComponentConfig } from '@coinbase/cds-mobile/core/componentConfig'; + import { ComponentConfigComparison } from './customerComponentConfig/ComponentConfigComparison'; import { customerComponentConfig } from './customerComponentConfig/customerComponentConfig'; import { retailCDSTheme } from './customerComponentConfig/retailCDSTheme'; +const emptyConfig: ComponentConfig = {}; const scrollContentContainerStyle = { flexGrow: 1 }; const tabsExampleData: TabValue[] = [ @@ -54,16 +62,130 @@ const SegmentedTabsExample = memo(() => { ); }); -const comparisonKeys = ['Accordion', 'Inputs', 'Tabs', 'SegmentedTabs'] as const; +const tabbedChipsExampleData: TabValue[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'activity', label: 'Activity' }, + { id: 'settings', label: 'Settings' }, +]; + +/** Self-contained Alpha TabbedChips example; TabComponent comes from customer component config. */ +const TabbedChipsExample = memo(() => { + const [activeTab, setActiveTab] = useState(tabbedChipsExampleData[0]); + return ( + + ); +}); + +const chipExampleLabel = 'Basic Chip'; + +const InputChipExample = memo(() => ( + + {}}> + {chipExampleLabel} + + {}}>{chipExampleLabel} + +)); + +const selectChipOptions = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, +]; + +const SelectChipExample = memo(() => { + const [value, setValue] = useState(null); + + return ( + + ); +}); + +const CheckboxCellExample = memo(() => { + const [checked, setChecked] = useState(false); + + return ( + + setChecked(!!nextChecked)} + title="Checkbox cell" + value="checkbox-cell" + /> + {}} + title="Selected and disabled" + value="checkbox-cell-disabled" + /> + + ); +}); + +const RadioCellExample = memo(() => { + const [value, setValue] = useState('option-1'); + + return ( + + { + if (next) setValue(next); + }} + title="Option 1" + value="option-1" + /> + { + if (next) setValue(next); + }} + title="Option 2" + value="option-2" + /> + + ); +}); + +const comparisonKeys = [ + 'Accordion', + 'Inputs', + 'InputChip', + 'SelectChip', + 'Tabs', + 'SegmentedTabs', + 'TabbedChips', + 'CheckboxCell', + 'RadioCell', +] as const; type ComparisonKey = (typeof comparisonKeys)[number]; const initialConfiguredState: Record = { Accordion: false, Inputs: false, + InputChip: false, + SelectChip: false, Tabs: false, SegmentedTabs: false, + TabbedChips: false, + CheckboxCell: false, + RadioCell: false, }; +const resolveSwitchChecked = (currentChecked: boolean, nextChecked?: boolean) => + typeof nextChecked === 'boolean' ? nextChecked : !currentChecked; + /** * Playground screen for iterating on a customer's CDS component config. * Wrap examples in {@link ComponentConfigComparison} to toggle stock CDS vs configured. @@ -73,41 +195,30 @@ export const CustomerComponentConfigScreen = memo(() => { const [useRetailTheme, setUseRetailTheme] = useState(false); const [configuredState, setConfiguredState] = useState(initialConfiguredState); - const handleToggleRetailTheme = useCallback((_: string | undefined, checked?: boolean) => { - setUseRetailTheme(Boolean(checked)); + const handleToggleRetailTheme = useCallback((_: string | undefined, nextChecked?: boolean) => { + setUseRetailTheme((prev) => resolveSwitchChecked(prev, nextChecked)); }, []); - const setConfigured = useCallback((key: ComparisonKey, checked: boolean) => { + const handleConfiguredChange = useCallback((key: ComparisonKey, checked: boolean) => { setConfiguredState((prev) => ({ ...prev, [key]: checked })); }, []); - const handleAccordionChange = useCallback( - (checked: boolean) => setConfigured('Accordion', checked), - [setConfigured], - ); - const handleInputsChange = useCallback( - (checked: boolean) => setConfigured('Inputs', checked), - [setConfigured], - ); - const handleTabsChange = useCallback( - (checked: boolean) => setConfigured('Tabs', checked), - [setConfigured], - ); - const handleSegmentedTabsChange = useCallback( - (checked: boolean) => setConfigured('SegmentedTabs', checked), - [setConfigured], - ); - const allConfigured = comparisonKeys.every((key) => configuredState[key]); + const someConfigured = comparisonKeys.some((key) => configuredState[key]); - const handleToggleAllConfigured = useCallback((_: string | undefined, checked?: boolean) => { - const nextChecked = Boolean(checked); - setConfiguredState( - comparisonKeys.reduce( - (next, key) => ({ ...next, [key]: nextChecked }), - {} as Record, - ), - ); - }, []); + const handleToggleAllConfigured = useCallback( + (_: string | undefined, switchChecked?: boolean) => { + setConfiguredState((prev) => { + const someOn = comparisonKeys.some((key) => prev[key]); + // Any configured -> turn all off; none configured -> turn all on. + const nextAllConfigured = typeof switchChecked === 'boolean' ? switchChecked : !someOn; + return comparisonKeys.reduce( + (next, key) => ({ ...next, [key]: nextAllConfigured }), + {} as Record, + ); + }); + }, + [], + ); return ( @@ -137,18 +248,20 @@ export const CustomerComponentConfigScreen = memo(() => { - - Toggle all - + + + Toggle all + + @@ -156,7 +269,7 @@ export const CustomerComponentConfigScreen = memo(() => { handleConfiguredChange('Accordion', checked)} > {() => ( @@ -172,7 +285,7 @@ export const CustomerComponentConfigScreen = memo(() => { handleConfiguredChange('Inputs', checked)} > {() => ( @@ -201,20 +314,55 @@ export const CustomerComponentConfigScreen = memo(() => { )} + handleConfiguredChange('InputChip', checked)} + > + {() => } + + handleConfiguredChange('SelectChip', checked)} + > + {() => } + handleConfiguredChange('Tabs', checked)} > {() => } handleConfiguredChange('SegmentedTabs', checked)} > {() => } + handleConfiguredChange('TabbedChips', checked)} + > + {() => } + + handleConfiguredChange('CheckboxCell', checked)} + > + {() => } + + handleConfiguredChange('RadioCell', checked)} + > + {() => } + diff --git a/apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx b/apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx index 97b15badc6..d84589bd21 100644 --- a/apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx +++ b/apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx @@ -21,6 +21,10 @@ export type ComponentConfigComparisonProps = { children: () => React.ReactNode; }; +/** Resolve the next checked value from Control's onChange, falling back to toggling local state. */ +const resolveSwitchChecked = (currentChecked: boolean, nextChecked?: boolean) => + typeof nextChecked === 'boolean' ? nextChecked : !currentChecked; + /** * Toggle orchestration for customer component config work. * Place inside a screen-level {@link ComponentConfigProvider} with the customer config; @@ -30,9 +34,9 @@ export const ComponentConfigComparison = memo( ({ componentName, checked, onChange, children }: ComponentConfigComparisonProps) => { const handleToggle = useCallback( (_: string | undefined, nextChecked?: boolean) => { - onChange(Boolean(nextChecked)); + onChange(resolveSwitchChecked(checked, nextChecked)); }, - [onChange], + [checked, onChange], ); return ( diff --git a/apps/expo-app/src/playground/customerComponentConfig/customTabComponent.tsx b/apps/expo-app/src/playground/customerComponentConfig/customTabComponent.tsx new file mode 100644 index 0000000000..77c5f57466 --- /dev/null +++ b/apps/expo-app/src/playground/customerComponentConfig/customTabComponent.tsx @@ -0,0 +1,38 @@ +import React, { memo, useCallback, useMemo } from 'react'; +import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; +import type { TabbedChipProps } from '@coinbase/cds-mobile/alpha/tabbed-chips/TabbedChips'; +import { Chip } from '@coinbase/cds-mobile/chips/Chip'; + +/** + * TabbedChips tab renderer stub — wired through {@link customerComponentConfig}. + * Uses tab context for active state and selection; customize chip styling/behavior below. + */ +export const CustomTabComponent = memo( + ({ + label = '', + id, + Component: _Component, + size, + ...chipRenderProps + }: TabbedChipProps) => { + const { activeTab, updateActiveTab } = useTabsContext(); + const isActive = useMemo(() => activeTab?.id === id, [activeTab, id]); + const handlePress = useCallback(() => updateActiveTab(id), [id, updateActiveTab]); + + return ( + + {label} + + ); + }, +); + +CustomTabComponent.displayName = 'CustomTabComponent'; diff --git a/apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts b/apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts index efcde05f1d..4b6d6e1b2d 100644 --- a/apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts +++ b/apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts @@ -1,4 +1,37 @@ -import type { ComponentConfig } from '@coinbase/cds-mobile/core/componentConfig'; +import type { SelectChipBaseProps } from '@coinbase/cds-mobile/alpha/select-chip/SelectChip'; +import type { TabbedChipsBaseProps } from '@coinbase/cds-mobile/alpha/tabbed-chips/TabbedChips'; +import type { InputChipBaseProps } from '@coinbase/cds-mobile/chips/ChipProps'; +import type { CheckboxCellBaseProps } from '@coinbase/cds-mobile/controls/CheckboxCell'; +import type { RadioCellBaseProps } from '@coinbase/cds-mobile/controls/RadioCell'; +import type { ComponentConfig, ConfigResolver } from '@coinbase/cds-mobile/core/componentConfig'; + +import { CustomTabComponent } from './customTabComponent'; + +const inputChipStyleConfigResolver: ConfigResolver = (props) => + props.active + ? { + activeBackground: 'bgSecondary', + borderWidth: 100, + borderColor: 'bgSecondary', + } + : { + background: 'bg', + borderWidth: 100, + borderColor: 'bgLine', + }; + +const selectChipStyleConfigResolver: ConfigResolver = (props) => + props.active + ? { + activeBackground: 'bgSecondary', + borderWidth: 100, + borderColor: 'bgSecondary', + } + : { + background: 'bg', + borderWidth: 100, + borderColor: 'bgLine', + }; /** * Customer component config under test. @@ -15,6 +48,7 @@ export const customerComponentConfig: ComponentConfig = { paddingY: 1, borderRadius: 400, }, + InputChip: inputChipStyleConfigResolver, TextInput: { borderRadius: 400, }, @@ -32,7 +66,17 @@ export const customerComponentConfig: ComponentConfig = { Select: { borderRadius: 400, }, + SelectChip: selectChipStyleConfigResolver, + TabbedChips: { + TabComponent: CustomTabComponent, + } satisfies ConfigResolver, DateInput: { borderRadius: 400, }, + CheckboxCell: { + borderRadius: 400, + }, + RadioCell: { + borderRadius: 400, + }, }; diff --git a/apps/expo-app/src/playground/customerComponentConfig/retailCDSTheme.ts b/apps/expo-app/src/playground/customerComponentConfig/retailCDSTheme.ts index d5b3e85ff6..3735be69d5 100644 --- a/apps/expo-app/src/playground/customerComponentConfig/retailCDSTheme.ts +++ b/apps/expo-app/src/playground/customerComponentConfig/retailCDSTheme.ts @@ -370,7 +370,7 @@ export const retailCDSTheme: ThemeConfig = { bgPositiveWash: `rgb(${darkSpectrum.green0})`, bgWarning: `rgb(${darkSpectrum.orange60})`, bgWarningWash: `rgb(${darkSpectrum.orange0})`, - bgLine: 'rgba(138,145,158,0.1)', + bgLine: 'rgba(138,145,158,0.2)', bgLineHeavy: 'rgba(138,145,158,0.66)', bgLineInverse: `rgb(${darkSpectrum.gray0})`, bgLinePrimary: `rgb(${darkSpectrum.blue70})`, diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index bbbefd1fcb..cfc18c80cb 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 9.16.0 (8/13/2026 PST) + +#### 🚀 Updates + +- Add utility for SelectChip to share across packages. [[#843](https://github.com/coinbase/cds/pull/843)] + ## 9.15.0 ((8/11/2026, 12:23 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 69f4cdb9c6..ca8a6356d4 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.15.0", + "version": "9.16.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/common/src/chips/__tests__/getSelectChipActive.test.ts b/packages/common/src/chips/__tests__/getSelectChipActive.test.ts new file mode 100644 index 0000000000..faab67169c --- /dev/null +++ b/packages/common/src/chips/__tests__/getSelectChipActive.test.ts @@ -0,0 +1,32 @@ +import { getSelectChipActive, getSelectChipHasValue } from '../getSelectChipActive'; + +describe('getSelectChipHasValue', () => { + it('returns false for null, undefined, and empty arrays', () => { + expect(getSelectChipHasValue(null)).toBe(false); + expect(getSelectChipHasValue(undefined)).toBe(false); + expect(getSelectChipHasValue([])).toBe(false); + }); + + it('returns true for selected single and multi values', () => { + expect(getSelectChipHasValue('option1')).toBe(true); + expect(getSelectChipHasValue(['option1'])).toBe(true); + }); +}); + +describe('getSelectChipActive', () => { + it('defaults to selection state when active is omitted and legacy invert props are unset', () => { + expect(getSelectChipActive(undefined, null)).toBe(false); + expect(getSelectChipActive(undefined, 'option1')).toBe(true); + }); + + it('respects explicit active overrides', () => { + expect(getSelectChipActive(false, 'option1')).toBe(false); + expect(getSelectChipActive(true, null)).toBe(true); + }); + + it('does not default active from selection when legacy invert props are set', () => { + expect(getSelectChipActive(undefined, 'option1', false)).toBe(false); + expect(getSelectChipActive(undefined, 'option1', true)).toBe(false); + expect(getSelectChipActive(undefined, null, true)).toBe(false); + }); +}); diff --git a/packages/common/src/chips/getSelectChipActive.ts b/packages/common/src/chips/getSelectChipActive.ts new file mode 100644 index 0000000000..d476589869 --- /dev/null +++ b/packages/common/src/chips/getSelectChipActive.ts @@ -0,0 +1,30 @@ +/** + * SelectChip selection: a single option, a multi-select list, or empty. + */ +type SelectChipValue = string | readonly string[] | null | undefined; + +/** + * Whether a SelectChip value represents a current selection. + */ +export function getSelectChipHasValue(value: SelectChipValue): boolean { + return value != null && !(Array.isArray(value) && value.length === 0); +} + +/** + * Resolves SelectChip `active` from an explicit prop, legacy invert props, or the current selection. + * Explicit `active={false}` stays inactive even when a value is selected. + * When `active` is omitted and neither legacy invert prop is set, defaults to whether a value is selected. + */ +export function getSelectChipActive( + active: boolean | undefined, + value: SelectChipValue, + invertColorScheme?: boolean, + inverted?: boolean, +): boolean { + return ( + active ?? + (invertColorScheme === undefined && inverted === undefined + ? getSelectChipHasValue(value) + : false) + ); +} diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index ad839782c3..5e0207de6d 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. +## 9.16.0 ((8/13/2026, 01:04 PM PST)) + +This is an artificial version bump with no new change. + ## 9.15.0 ((8/11/2026, 12:23 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 b6fa61a208..760dc51d2c 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "9.15.0", + "version": "9.16.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 425e065004..c929a03377 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. +## 9.16.0 (8/13/2026 PST) + +#### 🚀 Updates + +- Deprecate invertColorScheme in favor of new, canoncical active prop for the Chip family of props. [[#843](https://github.com/coinbase/cds/pull/843)] + ## 9.15.0 (8/11/2026 PST) #### 🚀 Updates diff --git a/packages/mobile/package.json b/packages/mobile/package.json index b37bbeab7c..4a76537454 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.15.0", + "version": "9.16.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/alpha/select-chip/SelectChip.tsx b/packages/mobile/src/alpha/select-chip/SelectChip.tsx index d72928581f..112a0a714d 100644 --- a/packages/mobile/src/alpha/select-chip/SelectChip.tsx +++ b/packages/mobile/src/alpha/select-chip/SelectChip.tsx @@ -1,23 +1,19 @@ -import React, { memo, useCallback } from 'react'; +import React, { memo, useMemo } from 'react'; +import { getSelectChipActive } from '@coinbase/cds-common/chips/getSelectChipActive'; -import type { ChipBaseProps } from '../../chips/ChipProps'; import { useComponentConfig } from '../../hooks/useComponentConfig'; import { Select, type SelectRef } from '../select/Select'; import type { SelectControlProps, SelectProps, SelectType } from '../select/types'; -import { SelectChipControl } from './SelectChipControl'; +import { + SelectChipControl, + type SelectChipControlChipProps, + type SelectChipControlProps, +} from './SelectChipControl'; -export type SelectChipBaseProps = Pick< - ChipBaseProps, - 'invertColorScheme' | 'numberOfLines' | 'maxWidth' | 'size' | 'compact' -> & { - /** - * Override the displayed value in the chip control. - * Useful for avoiding truncation, especially in multi-select scenarios where multiple option labels might be too long to display. - * When provided, this value takes precedence over the default label generation. - */ - displayValue?: React.ReactNode; -}; +// TODO(CDS-2544): SelectChipBaseProps should also compose SelectBaseProps. +// https://linear.app/coinbase/issue/CDS-2544/selectchipbaseprops-should-compose-selectbaseprops +export type SelectChipBaseProps = Pick; export type SelectChipProps< Type extends SelectType = 'single', @@ -25,15 +21,39 @@ export type SelectChipProps< > = SelectChipBaseProps & Omit< SelectProps, - | 'SelectControlComponent' - | 'helperText' - | 'labelVariant' - | 'variant' - | 'maxWidth' - | 'size' - | 'compact' + | 'SelectControlComponent' // fixed to SelectChipControl + | 'helperText' // not supported + | 'labelVariant' // not supported + | 'variant' // not supported + | 'maxWidth' // chip-owned + | 'size' // ChipSize, not SelectSize + | 'compact' // chip-owned + | 'bordered' // chip-owned + | 'borderWidth' // chip-owned + | 'borderColor' // chip-owned + | 'borderRadius' // chip-owned >; +/** + * Creates a wrapper component that injects chip-specific props into SelectChipControl. + * Select only forwards generic control props to `SelectControlComponent`; chip styling + * and `active` are owned by SelectChip and must be closed over here. + */ +function createSelectChipControlWrapper< + Type extends SelectType, + SelectOptionValue extends string = string, +>(chipProps: SelectChipBaseProps): React.FC> { + return memo((controlProps: SelectControlProps) => { + // Chip props are spread last so they win at runtime; the cast bridges overlapping keys + // (`size`, borders) where SelectControlProps and SelectChipBaseProps use different types. + return ( + )} + /> + ); + }); +} + /** * Chip-styled Select control built on top of the Alpha Select. * Supports both single and multi selection via Select's `type` prop. @@ -45,34 +65,85 @@ const SelectChipComponent = memo( }: SelectChipProps & { ref?: React.Ref; }) => { - const mergedProps = useComponentConfig('SelectChip', _props); - const { invertColorScheme, numberOfLines, maxWidth, displayValue, size, compact, ...props } = - mergedProps; - // Select doesn't pass the chip-specific props (size/compact/displayValue/etc.) down to the - // control, so they're injected here. They're listed AFTER the `controlProps` spread so the - // chip-level values win — matching the web SelectChip wrapper. - const SelectChipControlComponent = useCallback( - (controlProps: SelectControlProps) => { - return ( - - ); - }, - [displayValue, invertColorScheme, maxWidth, numberOfLines, size, compact], + // Resolve `active` from instance props before config merge so state-aware resolvers + // (active vs inactive border styling) see the final selection state. `value` and + // legacy invert props are pulled out with `active` and passed back explicitly because + // they feed that resolution and must remain available to the config resolver input. + const { active: activeProp, invertColorScheme, inverted, value, ...restProps } = _props; + const resolvedActive = getSelectChipActive(activeProp, value, invertColorScheme, inverted); + const mergedProps = useComponentConfig('SelectChip', { + ...restProps, + value, + active: resolvedActive, + invertColorScheme, + inverted, + }); + // SelectChip composes Select + MediaChip; peel chip props off merged props so the rest + // can flow to `` without chip-only fields leaking into the select interface. + const { + active, + activeBackground, + activeColor, + background, + color, + numberOfLines, + maxWidth, + displayValue, + size, + compact, + borderWidth, + borderColor, + bordered, + borderRadius, + invertColorScheme: mergedInvertColorScheme, + inverted: mergedInverted, + ...selectProps + } = mergedProps; const WrappedSelectChipControl = useMemo( () => createSelectChipControlWrapper({ - invertColorScheme, + active, + activeBackground, + activeColor, + background, + color, numberOfLines, maxWidth, displayValue, size, compact, + borderWidth, + borderColor, + bordered, + borderRadius, + invertColorScheme: mergedInvertColorScheme, + inverted: mergedInverted, }), - [displayValue, invertColorScheme, numberOfLines, maxWidth, size, compact], + [ + active, + activeBackground, + activeColor, + background, + color, + displayValue, + numberOfLines, + maxWidth, + size, + compact, + borderWidth, + borderColor, + bordered, + borderRadius, + mergedInvertColorScheme, + mergedInverted, + ], ); return ( @@ -112,9 +154,9 @@ const SelectChipComponent = memo( dropdown: { width: 'max-content', }, - ...props.styles, + ...selectProps.styles, }} - {...props} + {...selectProps} /> ); }, diff --git a/packages/web/src/alpha/select-chip/SelectChipControl.tsx b/packages/web/src/alpha/select-chip/SelectChipControl.tsx index e7d98dcbab..ea4f832d5e 100644 --- a/packages/web/src/alpha/select-chip/SelectChipControl.tsx +++ b/packages/web/src/alpha/select-chip/SelectChipControl.tsx @@ -1,5 +1,7 @@ import React, { forwardRef, memo, useMemo } from 'react'; +import { getSelectChipHasValue } from '@coinbase/cds-common/chips/getSelectChipActive'; +import type { ChipBaseProps } from '../../chips/ChipProps'; import { MediaChip } from '../../chips/MediaChip'; import { AnimatedCaret } from '../../motion/AnimatedCaret'; import type { SelectRef } from '../select/Select'; @@ -10,7 +12,52 @@ import { type SelectType, } from '../select/types'; -import type { SelectChipBaseProps } from './SelectChip'; +/** + * Chip props accepted by {@link SelectChipControl} and forwarded to {@link MediaChip}. + * Includes selection state, layout, borders, and `displayValue` for the control label. + */ +export type SelectChipControlChipProps = Pick< + ChipBaseProps, + | 'active' + | 'activeBackground' + | 'activeColor' + | 'background' + | 'color' + | 'invertColorScheme' + | 'inverted' + | 'numberOfLines' + | 'maxWidth' + | 'size' + | 'compact' + | 'borderWidth' + | 'borderColor' + | 'bordered' + | 'borderRadius' +> & { + /** + * Override the displayed value in the chip control. + * Useful for avoiding truncation, especially in multi-select scenarios where multiple option labels might be too long to display. + * When provided, this value takes precedence over the default label generation. + */ + displayValue?: React.ReactNode; +}; + +/** + * SelectControlProps fields superseded by {@link SelectChipControlChipProps}. + * Omit is required: intersection would merge overlapping keys (e.g. `size` as SelectSize & + * ChipSize, `background` as responsive Box vs chip tokens) instead of replacing select-scale + * definitions. + */ +type SelectControlPropsReplacedByChipProps = keyof Pick< + SelectControlProps, + 'size' | 'compact' | 'borderWidth' | 'borderColor' | 'bordered' | 'borderRadius' +>; + +export type SelectChipControlProps< + Type extends SelectType = 'single', + SelectOptionValue extends string = string, +> = Omit, SelectControlPropsReplacedByChipProps> & + SelectChipControlChipProps; const SelectChipControlComponent = memo( forwardRef( @@ -31,19 +78,28 @@ const SelectChipControlComponent = memo( maxSelectedOptionsToShow = 2, hiddenSelectedOptionsLabel = 'more', label, + disabled, compact, size, + active, + activeBackground, + activeColor, + background, + color, invertColorScheme, + inverted, numberOfLines, - disabled, maxWidth, displayValue, - }: Omit, 'size' | 'compact'> & - SelectChipBaseProps, + borderWidth, + borderColor, + bordered, + borderRadius, + }: SelectChipControlProps, ref: React.Ref, ) => { const isMultiSelect = type === 'multi'; - const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); + const hasValue = getSelectChipHasValue(value); // Map of options to their values // If multiple options share the same value, the first occurrence wins (matches native HTML select behavior) @@ -123,40 +179,38 @@ const SelectChipControlComponent = memo( hiddenSelectedOptionsLabel, ]); + const resolvedColor = active && activeColor !== undefined ? activeColor : color; + const endNode = useMemo(() => { + // Match Chip's label color. `fg` still inverts with the chip when active uses theme inversion. return ( customEndNode ?? ( - + ) ); - }, [customEndNode, hasValue, open]); - - const color = useMemo(() => { - return hasValue ? 'fgInverse' : 'fg'; - }, [hasValue]); - - const background = useMemo(() => { - return hasValue ? 'bgInverse' : 'bgSecondary'; - }, [hasValue]); + }, [customEndNode, open, resolvedColor]); return ( } noScaleOnPress accessibilityLabel={accessibilityLabel} + active={active} + activeBackground={activeBackground} + activeColor={activeColor} aria-haspopup={ariaHaspopup} background={background} + bordered={bordered} + borderColor={borderColor} + borderRadius={borderRadius} + borderWidth={borderWidth} className={className} color={color} compact={compact} disabled={disabled} end={endNode} invertColorScheme={invertColorScheme} + inverted={inverted} maxWidth={maxWidth} numberOfLines={numberOfLines} onClick={() => setOpen((s) => !s)} @@ -171,12 +225,14 @@ const SelectChipControlComponent = memo( ), ); +SelectChipControlComponent.displayName = 'SelectChipControl'; + +/** Generic memo components need an explicit cast for the public polymorphic signature. */ export const SelectChipControl = SelectChipControlComponent as < Type extends SelectType, SelectOptionValue extends string = string, >( - props: Omit, 'size' | 'compact'> & - SelectChipBaseProps & { - ref?: React.Ref; - }, + props: SelectChipControlProps & { + ref?: React.Ref; + }, ) => React.ReactElement; diff --git a/packages/web/src/alpha/select-chip/__figma__/SelectChip.figma.ts b/packages/web/src/alpha/select-chip/__figma__/SelectChip.figma.ts index 614257f9b5..4e862d7715 100644 --- a/packages/web/src/alpha/select-chip/__figma__/SelectChip.figma.ts +++ b/packages/web/src/alpha/select-chip/__figma__/SelectChip.figma.ts @@ -27,6 +27,9 @@ const disabled = instance.getEnum('state', { // show start: whether the startNode slot is populated const showStart = instance.getEnum('show start', { true: true, false: false }); +// active: Figma active variant maps directly to the active prop +const active = instance.getEnum('active', { true: true, false: false }); + // The start element uses different instance swaps for the xs vs s size const startCompact = instance.getInstanceSwap('↳ startCompact'); const startRegular = instance.getInstanceSwap('↳ start'); @@ -40,6 +43,7 @@ if (showStart && startHandle && startHandle.type === 'INSTANCE') { export default { example: figma.code` {}} diff --git a/packages/web/src/alpha/select-chip/__stories__/SelectChip.stories.tsx b/packages/web/src/alpha/select-chip/__stories__/SelectChip.stories.tsx index 5350e9cd5c..f74048da56 100644 --- a/packages/web/src/alpha/select-chip/__stories__/SelectChip.stories.tsx +++ b/packages/web/src/alpha/select-chip/__stories__/SelectChip.stories.tsx @@ -213,7 +213,7 @@ export const MultiSelectWithAssets = () => { ); }; -export const InvertColorScheme = () => { +export const Active = () => { const exampleOptions = [ { value: '1', label: 'Option 1' }, { value: '2', label: 'Option 2' }, @@ -223,7 +223,7 @@ export const InvertColorScheme = () => { return ( = { placeholder: 'Select an option', }; +const getSelectChipControl = () => screen.getByRole('button'); + +const expectInvertedChip = (chip: HTMLElement) => { + expect(chip.parentElement).toHaveClass('dark'); +}; + +const expectNonInvertedChip = (chip: HTMLElement) => { + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); +}; + describe('SelectChip', () => { beforeEach(() => { jest.clearAllMocks(); @@ -107,6 +119,163 @@ describe('SelectChip', () => { expect(screen.getByText('Select an option')).toBeInTheDocument(); }); + it('defaults to active colors when a value is selected', () => { + render( + + + , + ); + + const chip = screen.getByRole('button'); + const activeWrapper = chip.parentElement; + expect(activeWrapper).toHaveClass('dark'); + expect(activeWrapper).toHaveStyle({ + '--color-bgSecondary': defaultTheme.darkColor.bgSecondary, + '--color-fg': defaultTheme.darkColor.fg, + }); + }); + + it('renders inactive colors when no value is selected', () => { + render( + + + , + ); + + const chip = screen.getByRole('button'); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); + + it('respects explicit active={false} when a value is selected', () => { + render( + + + , + ); + + const chip = screen.getByRole('button'); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); + + it('respects explicit invertColorScheme={false} opt-out when a value is selected', () => { + render( + + + , + ); + + const chip = screen.getByRole('button'); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); + + it('applies legacy invertColorScheme when no value is selected', () => { + render( + + + , + ); + + const chip = screen.getByRole('button'); + expect(chip.parentElement).toHaveClass('dark'); + }); + + it('applies border props from ComponentConfigProvider', () => { + render( + + + + + , + ); + + expect(screen.getByRole('button')).toHaveStyle({ + borderColor: 'var(--color-bgLine)', + borderWidth: 'var(--borderWidth-100)', + }); + }); + + it('forces active chip colors when active is true without a selected value', () => { + render( + + + , + ); + + expectInvertedChip(getSelectChipControl()); + }); + + it('applies activeBackground without inverting when a value is selected', () => { + render( + + + , + ); + + const chip = getSelectChipControl(); + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgPositive)' }); + expect(screen.getByText('Option 1')).toHaveStyle({ color: 'var(--color-fgPositive)' }); + expectNonInvertedChip(chip); + }); + + it('applies color to the default caret', () => { + render( + + + , + ); + + expect(screen.getByTestId('icon-base-glyph')).toHaveStyle({ + color: 'var(--color-fgPrimary)', + }); + }); + + it('applies activeColor to the default caret when a value is selected', () => { + render( + + + , + ); + + expect(screen.getByTestId('icon-base-glyph')).toHaveStyle({ + color: 'var(--color-fgPositive)', + }); + }); + + it('applies active branch styling from ComponentConfigProvider when a value is selected', () => { + render( + + + props.active + ? { activeBackground: 'bgPositive', activeColor: 'fgPositive' } + : { background: 'bg' }, + }} + > + + + , + ); + + const chip = getSelectChipControl(); + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgPositive)' }); + expect(screen.getByText('Option 1')).toHaveStyle({ color: 'var(--color-fgPositive)' }); + expectNonInvertedChip(chip); + }); + it('opens dropdown when clicked', async () => { const user = userEvent.setup(); render( @@ -279,6 +448,26 @@ describe('SelectChip', () => { expect(screen.getByText('Option 1, Option 2')).toBeInTheDocument(); }); + it('defaults to inactive chip colors for an empty multi-select value', () => { + render( + + + , + ); + + expectNonInvertedChip(getSelectChipControl()); + }); + + it('defaults to active chip colors when multi-select has values', () => { + render( + + + , + ); + + expectInvertedChip(getSelectChipControl()); + }); + it('shows truncated selection with more count', () => { render( diff --git a/packages/web/src/alpha/select-chip/index.tsx b/packages/web/src/alpha/select-chip/index.tsx index 6e9f896f2a..49ffc9005e 100644 --- a/packages/web/src/alpha/select-chip/index.tsx +++ b/packages/web/src/alpha/select-chip/index.tsx @@ -1,2 +1,2 @@ export * from './SelectChip'; -export * from './SelectChipControl'; +export { SelectChipControl } from './SelectChipControl'; diff --git a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx index 57cf18fc33..f94179096d 100644 --- a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx +++ b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx @@ -34,6 +34,7 @@ const scrollContainerCss = css` const DefaultTabComponent = ({ label = '', id, + Component: _Component, activeBackground, activeColor, color, @@ -60,10 +61,10 @@ const DefaultTabComponent = ({ return ( = Omit< Component?: React.FC & TabValue>; /** * Custom background color applied to the chip when it is the active tab. - * When set, takes precedence over the default `invertColorScheme` behavior. + * Skips color-scheme inversion and paints this token directly. Any `start`, + * `end`, or custom tab content must set explicit colors to match. */ activeBackground?: MediaChipBaseProps['background']; /** * Custom foreground color applied to the chip label when it is the active tab. + * Skips color-scheme inversion when set. Any `start`, `end`, or custom tab + * content must set explicit colors to match. */ activeColor?: MediaChipBaseProps['color']; }; diff --git a/packages/web/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx b/packages/web/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx index 83c2f15542..e0eee872a2 100644 --- a/packages/web/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx +++ b/packages/web/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx @@ -94,26 +94,37 @@ describe('TabbedChips(Alpha) - web', () => { }); describe('activeBackground', () => { - it('renders without error when tabs have activeBackground set', () => { + it('paints activeBackground on the selected tab without inverting', () => { render(); - expect(screen.getByTestId(testID)).toBeDefined(); + const first = screen.getByTestId( + activeBackgroundTabs[0].testID ?? activeBackgroundTabs[0].id, + ); + const second = screen.getByTestId( + activeBackgroundTabs[1].testID ?? activeBackgroundTabs[1].id, + ); + + expect(first).toHaveAttribute('aria-selected', 'true'); + expect(first).toHaveStyle({ backgroundColor: 'var(--color-bgPositive)' }); + expect(first.parentElement?.className).not.toMatch(/\bdark\b/); + expect(second).toHaveAttribute('aria-selected', 'false'); + expect(second).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); }); - it('active tab with activeBackground still carries aria-selected', async () => { + it('moves activeBackground to the newly selected tab', async () => { render(); - const firstTestId = activeBackgroundTabs[0].testID ?? activeBackgroundTabs[0].id; - const secondTestId = activeBackgroundTabs[1].testID ?? activeBackgroundTabs[1].id; - - expect(screen.getByTestId(firstTestId)).toHaveAttribute('aria-selected', 'true'); - - fireEvent.click(screen.getByTestId(secondTestId)); - - await waitFor(() => - expect(screen.getByTestId(secondTestId)).toHaveAttribute('aria-selected', 'true'), + const first = screen.getByTestId( + activeBackgroundTabs[0].testID ?? activeBackgroundTabs[0].id, ); - await waitFor(() => - expect(screen.getByTestId(firstTestId)).toHaveAttribute('aria-selected', 'false'), + const second = screen.getByTestId( + activeBackgroundTabs[1].testID ?? activeBackgroundTabs[1].id, ); + + fireEvent.click(second); + + await waitFor(() => expect(second).toHaveAttribute('aria-selected', 'true')); + expect(second).toHaveStyle({ backgroundColor: 'var(--color-bgPositive)' }); + expect(first).toHaveAttribute('aria-selected', 'false'); + expect(first).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); }); }); @@ -159,26 +170,22 @@ describe('TabbedChips(Alpha) - web', () => { }); describe('activeColor', () => { - it('renders without error when tabs have activeColor set', () => { + it('applies activeColor to the selected tab label', () => { render(); - expect(screen.getByTestId(testID)).toBeDefined(); + + expect(screen.getByText('Tab one')).toHaveStyle({ color: 'var(--color-fgPositive)' }); + expect(screen.getByText('Tab two')).toHaveStyle({ color: 'var(--color-fg)' }); }); - it('active tab with activeColor still carries aria-selected', async () => { + it('moves activeColor to the newly selected tab', async () => { render(); - const firstTestId = activeColorTabs[0].testID ?? activeColorTabs[0].id; - const secondTestId = activeColorTabs[1].testID ?? activeColorTabs[1].id; - expect(screen.getByTestId(firstTestId)).toHaveAttribute('aria-selected', 'true'); + fireEvent.click(screen.getByTestId(activeColorTabs[1].testID ?? activeColorTabs[1].id)); - fireEvent.click(screen.getByTestId(secondTestId)); - - await waitFor(() => - expect(screen.getByTestId(secondTestId)).toHaveAttribute('aria-selected', 'true'), - ); await waitFor(() => - expect(screen.getByTestId(firstTestId)).toHaveAttribute('aria-selected', 'false'), + expect(screen.getByText('Tab two')).toHaveStyle({ color: 'var(--color-fgPositive)' }), ); + expect(screen.getByText('Tab one')).toHaveStyle({ color: 'var(--color-fg)' }); }); }); }); diff --git a/packages/web/src/chips/Chip.tsx b/packages/web/src/chips/Chip.tsx index 88b74dd661..d08f29d039 100644 --- a/packages/web/src/chips/Chip.tsx +++ b/packages/web/src/chips/Chip.tsx @@ -1,4 +1,4 @@ -import { forwardRef, Fragment, memo, type ReactNode, useMemo } from 'react'; +import { forwardRef, Fragment, memo, useMemo } from 'react'; import { curves, durations } from '@coinbase/cds-common/motion/tokens'; import { chipMaxWidth } from '@coinbase/cds-common/tokens/chip'; import { css } from '@linaria/core'; @@ -69,6 +69,9 @@ export const Chip = memo( justifyContent, children, maxWidth = chipMaxWidth, + active = false, + activeBackground, + activeColor, invertColorScheme, inverted, numberOfLines = 1, @@ -85,10 +88,18 @@ export const Chip = memo( onClick, ...props } = mergedProps; - const WrapperComponent = (invertColorScheme ?? inverted) ? InvertedThemeProvider : Fragment; + + const hasActiveColorOverrides = activeBackground !== undefined || activeColor !== undefined; + const activeUsesThemeInversion = active && !hasActiveColorOverrides; + const shouldInvert = Boolean(invertColorScheme ?? inverted) || activeUsesThemeInversion; + const WrapperComponent = shouldInvert ? InvertedThemeProvider : Fragment; + + const resolvedBackground = + active && activeBackground !== undefined ? activeBackground : background; + const resolvedColor = active && activeColor !== undefined ? activeColor : color; const containerProps = { - background, + background: resolvedBackground, borderRadius, className: cx(transitionCss, className, classNames?.root), style: { ...style, ...styles?.root }, @@ -117,11 +128,11 @@ export const Chip = memo( > {start} {typeof children === 'string' ? ( - + {children} ) : children ? ( - + {children} ) : null} @@ -145,14 +156,14 @@ export const Chip = memo( styles?.content, start, children, - color, + resolvedColor, font, numberOfLines, end, ]); return ( - + {onClick ? ( } diff --git a/packages/web/src/chips/ChipProps.ts b/packages/web/src/chips/ChipProps.ts index 62e7b60eb1..4a525585fb 100644 --- a/packages/web/src/chips/ChipProps.ts +++ b/packages/web/src/chips/ChipProps.ts @@ -1,6 +1,8 @@ +import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; +import type { StyleProps } from '../styles/styleProps'; import type { PressableBaseProps, PressableDefaultElement, @@ -33,18 +35,50 @@ export type ChipBaseProps = SharedProps & * @default 200 */ maxWidth?: PressableBaseProps['maxWidth']; + /** + * When true, emphasizes the Chip with higher contrast by inverting the color + * scheme for everything rendered inside the Chip — including `background`, + * `color`, icons, and other token-based colors. Those props are resolved + * against the opposite color scheme, matching the legacy `invertColorScheme` + * behavior. + * + * Set `activeBackground` and/or `activeColor` to opt out of inversion and + * paint explicit active colors instead. + * @default false + */ + active?: boolean; + /** + * Background color applied when `active` is true. When set, the Chip skips + * color-scheme inversion and uses this token directly. + * + * **Warning:** `start`, `end`, and `children` ReactNodes are not updated + * automatically — pass explicit `color` props on nested icons and other + * content so they match the active palette. + */ + activeBackground?: ThemeVars.Color; + /** + * Foreground color applied when `active` is true. When set, the Chip skips + * color-scheme inversion and uses this token for string labels. + * + * **Warning:** `start`, `end`, and `children` ReactNodes are not updated + * automatically — pass explicit `color` props on nested icons and other + * content so they match the active palette. + */ + activeColor?: StyleProps['color']; /** * Invert the foreground and background colors to emphasize the Chip. * Depending on your theme, it may be dangerous to use this prop in conjunction with `transparentWhileInactive`. * @default false - * @deprecated Use the invertColorScheme prop instead. This will be removed in a future major release. - * @deprecationExpectedRemoval v9 + * @deprecated Use the `active` prop instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v11 */ inverted?: boolean; /** * Invert the foreground and background colors to emphasize the Chip. * Depending on your theme, it may be dangerous to use this prop in conjunction with `transparentWhileInactive`. * @default false + * @deprecated Use the `active` prop instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v11 */ invertColorScheme?: boolean; /** diff --git a/packages/web/src/chips/InputChip.tsx b/packages/web/src/chips/InputChip.tsx index dfa964e21f..8f4ce11193 100644 --- a/packages/web/src/chips/InputChip.tsx +++ b/packages/web/src/chips/InputChip.tsx @@ -8,19 +8,32 @@ import { MediaChip } from './MediaChip'; export const InputChip = memo( forwardRef((_props: InputChipProps, ref: React.ForwardedRef) => { - const mergedProps = useComponentConfig('InputChip', _props); + const { active: activeProp, invertColorScheme, inverted, ...restProps } = _props; + // Default before component config so state-aware resolvers (active borders) see the + // same active semantics as the rendered chip. + const active = + activeProp ?? (invertColorScheme === undefined && inverted === undefined ? true : false); + const mergedProps = useComponentConfig('InputChip', { + ...restProps, + active, + invertColorScheme, + inverted, + }); const { value, children = value, accessibilityLabel = typeof children === 'string' ? `Remove ${children}` : 'Remove option', - invertColorScheme = true, testID = 'input-chip', + active: _active, + invertColorScheme: mergedInvertColorScheme, + inverted: mergedInverted, ...props } = mergedProps; return ( } - invertColorScheme={invertColorScheme} + invertColorScheme={mergedInvertColorScheme} + inverted={mergedInverted} + testID={testID} {...props} > {children} diff --git a/packages/web/src/chips/__figma__/InputChip.figma.ts b/packages/web/src/chips/__figma__/InputChip.figma.ts index 39d1dc637c..08229efa08 100644 --- a/packages/web/src/chips/__figma__/InputChip.figma.ts +++ b/packages/web/src/chips/__figma__/InputChip.figma.ts @@ -26,6 +26,9 @@ const showStart = instance.getEnum('show start', { true: true, false: false }); // show label: whether the label text is visible (false = icon-only chip) const showLabel = instance.getEnum('show label', { true: true, false: false }); +// active: Figma active variant maps directly to the active prop +const active = instance.getEnum('active', { true: true, false: false }); + // The start element uses different instance swaps depending on the size const startCompact = instance.getInstanceSwap('↳ startCompact'); const startRegular = instance.getInstanceSwap('↳ start'); @@ -39,6 +42,7 @@ if (showStart && startHandle && startHandle.type === 'INSTANCE') { export default { example: figma.code`${showLabel ? label : ''}`, diff --git a/packages/web/src/chips/__stories__/Chip.stories.tsx b/packages/web/src/chips/__stories__/Chip.stories.tsx index 00c6dad8bd..cf0cf079a2 100644 --- a/packages/web/src/chips/__stories__/Chip.stories.tsx +++ b/packages/web/src/chips/__stories__/Chip.stories.tsx @@ -19,7 +19,7 @@ const ChipExamples = ({ ...props }: { label?: string; direction?: 'row' | 'column' } & Pick< ChipBaseProps, - 'inverted' | 'compact' | 'size' + 'active' | 'compact' | 'size' >) => { const divRef = useRef(null); const buttonRef = useRef(null); @@ -104,9 +104,9 @@ export const Default = () => ( - Inverted + Active - + Compact diff --git a/packages/web/src/chips/__stories__/MediaChip.stories.tsx b/packages/web/src/chips/__stories__/MediaChip.stories.tsx index 9c2806bfec..a27ab2b84e 100644 --- a/packages/web/src/chips/__stories__/MediaChip.stories.tsx +++ b/packages/web/src/chips/__stories__/MediaChip.stories.tsx @@ -19,7 +19,7 @@ const MediaChipExamples = ({ ...props }: { label?: string; direction?: 'row' | 'column' } & Pick< ChipBaseProps, - 'inverted' | 'compact' + 'active' | 'compact' >) => { const divRef = useRef(null); const buttonRef = useRef(null); @@ -73,9 +73,9 @@ export const Default = () => ( - Inverted + Active - + Compact diff --git a/packages/web/src/chips/__tests__/Chip.test.tsx b/packages/web/src/chips/__tests__/Chip.test.tsx index 956b6943e3..06b45afbed 100644 --- a/packages/web/src/chips/__tests__/Chip.test.tsx +++ b/packages/web/src/chips/__tests__/Chip.test.tsx @@ -98,4 +98,89 @@ describe('Chip', () => { expect(chip).toHaveStyle('border: 2px solid red'); expect(chip.firstElementChild).toHaveStyle('padding: 10px'); }); + + it('renders inactive colors by default', () => { + render(); + + const chip = screen.getByTestId(testID); + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); + + it('renders opposite-scheme bgSecondary and fg when invertColorScheme is true', () => { + render(); + + const chip = screen.getByTestId(testID); + const invertedWrapper = chip.parentElement; + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(invertedWrapper).toHaveClass('dark'); + expect(invertedWrapper).toHaveStyle({ + '--color-bgSecondary': defaultTheme.darkColor.bgSecondary, + '--color-fg': defaultTheme.darkColor.fg, + }); + }); + + it('renders opposite-scheme bgSecondary and fg when inverted is true', () => { + render(); + + const chip = screen.getByTestId(testID); + const invertedWrapper = chip.parentElement; + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(invertedWrapper).toHaveClass('dark'); + expect(invertedWrapper).toHaveStyle({ + '--color-bgSecondary': defaultTheme.darkColor.bgSecondary, + '--color-fg': defaultTheme.darkColor.fg, + // Current legacy behavior uses the invalid CSS value "content" (not "contents"). + display: 'content', + }); + }); + + it('does not invert when invertColorScheme is false even if inverted is true', () => { + render(); + + const chip = screen.getByTestId(testID); + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); + + it('renders opposite-scheme bgSecondary and fg when active is true', () => { + render(); + + const chip = screen.getByTestId(testID); + const activeWrapper = chip.parentElement; + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(activeWrapper).toHaveClass('dark'); + expect(activeWrapper).toHaveStyle({ + '--color-bgSecondary': defaultTheme.darkColor.bgSecondary, + '--color-fg': defaultTheme.darkColor.fg, + }); + }); + + it('applies activeBackground and activeColor without inverting when active', () => { + render( + + + USD + + , + ); + + const chip = screen.getByTestId(testID); + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgPositive)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fgPositive)' }); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); + + it('prefers style overrides when active', () => { + render( + , + ); + + expect(screen.getByTestId(testID)).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' }); + }); }); diff --git a/packages/web/src/chips/__tests__/InputChip.test.tsx b/packages/web/src/chips/__tests__/InputChip.test.tsx index 8fedb0f4de..3a0e5f75a6 100644 --- a/packages/web/src/chips/__tests__/InputChip.test.tsx +++ b/packages/web/src/chips/__tests__/InputChip.test.tsx @@ -4,6 +4,7 @@ import { renderA11y } from '@coinbase/cds-web-utils'; import { fireEvent, render, screen } from '@testing-library/react'; import { RemoteImage } from '../../media'; +import { defaultTheme } from '../../themes/defaultTheme'; import { DefaultThemeProvider } from '../../utils/test'; import type { InputChipProps } from '../ChipProps'; import { InputChip } from '../InputChip'; @@ -56,4 +57,44 @@ describe('InputChip', () => { expect(screen.getByLabelText('Remove USD')).toBeTruthy(); }); + + it('defaults to active colors', () => { + render( {}}>USD); + + const chip = screen.getByTestId(chipTestID); + const activeWrapper = chip.parentElement; + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(activeWrapper).toHaveClass('dark'); + expect(activeWrapper).toHaveStyle({ + '--color-bgSecondary': defaultTheme.darkColor.bgSecondary, + '--color-fg': defaultTheme.darkColor.fg, + }); + }); + + it('renders inactive colors when active is false', () => { + render( + {}}> + USD + , + ); + + const chip = screen.getByTestId(chipTestID); + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); + + it('respects explicit invertColorScheme={false} opt-out', () => { + render( + {}}> + USD + , + ); + + const chip = screen.getByTestId(chipTestID); + expect(chip).toHaveStyle({ backgroundColor: 'var(--color-bgSecondary)' }); + expect(screen.getByText('USD')).toHaveStyle({ color: 'var(--color-fg)' }); + expect(chip.parentElement?.className).not.toMatch(/\bdark\b/); + }); }); diff --git a/packages/web/src/visualizations/chart/legend/__stories__/Legend.stories.tsx b/packages/web/src/visualizations/chart/legend/__stories__/Legend.stories.tsx index b3e98bab56..2d3be84c62 100644 --- a/packages/web/src/visualizations/chart/legend/__stories__/Legend.stories.tsx +++ b/packages/web/src/visualizations/chart/legend/__stories__/Legend.stories.tsx @@ -490,7 +490,7 @@ const Interactive = () => { compact accessibilityLabel={`${isEmphasized ? 'Remove emphasis from' : 'Emphasize'} ${label} series`} aria-pressed={isEmphasized} - invertColorScheme={isEmphasized} + active={isEmphasized} onClick={() => handleToggle(seriesId)} style={{ backgroundColor: `rgb(var(${baseColor}10))`,