From b27980fd424dd187db02ca556dbe91eb7b9ba913 Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Thu, 6 Aug 2026 17:18:28 -0500 Subject: [PATCH 1/6] Add expo-app playground screen for customer component config testing. Co-Authored-By: Claude Co-authored-by: Cursor --- .../CustomerComponentConfigScreen.tsx | 27 ++++++++++ .../src/playground/ExamplesListScreen.tsx | 22 +++++--- apps/expo-app/src/playground/Playground.tsx | 6 +++ .../ComponentConfigComparison.tsx | 52 +++++++++++++++++++ .../customerComponentConfig.ts | 7 +++ apps/expo-app/src/playground/types.ts | 1 + 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx create mode 100644 apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx create mode 100644 apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts diff --git a/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx b/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx new file mode 100644 index 0000000000..8e62f5b6c8 --- /dev/null +++ b/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx @@ -0,0 +1,27 @@ +import React, { memo } from 'react'; +import { ScrollView } from 'react-native'; +import { Button } from '@coinbase/cds-mobile/buttons/Button'; +import { VStack } from '@coinbase/cds-mobile/layout/VStack'; +import { ComponentConfigProvider } from '@coinbase/cds-mobile/system/ComponentConfigProvider'; + +import { ComponentConfigComparison } from './customerComponentConfig/ComponentConfigComparison'; +import { customerComponentConfig } from './customerComponentConfig/customerComponentConfig'; + +/** + * Playground screen for iterating on a customer's CDS component config. + * Wrap examples in {@link ComponentConfigComparison} to toggle stock CDS vs configured. + */ +export const CustomerComponentConfigScreen = memo(() => { + return ( + + + + {/* Placeholder: both modes match until customerComponentConfig defines Button. */} + + {() => } + + + + + ); +}); diff --git a/apps/expo-app/src/playground/ExamplesListScreen.tsx b/apps/expo-app/src/playground/ExamplesListScreen.tsx index e01be5414e..2974a85e8c 100644 --- a/apps/expo-app/src/playground/ExamplesListScreen.tsx +++ b/apps/expo-app/src/playground/ExamplesListScreen.tsx @@ -12,6 +12,7 @@ import { keyToRouteName } from './keyToRouteName'; import type { ExamplesListScreenProps } from './types'; const innerSpacingConfig: CellSpacing = { paddingX: 1 }; +const pinnedRouteKeys = ['CustomerComponentConfig']; export function ExamplesListScreen({ route }: ExamplesListScreenProps) { const { filter, isOpen, resetSearch, closeSearch } = useContext(SearchContext); @@ -29,7 +30,8 @@ export function ExamplesListScreen({ route }: ExamplesListScreenProps) { // Shown as a shortcut button above the filtered list when search is active. const exactMatch = useMemo(() => { if (!isOpen || filter.length === 0) return null; - return routeKeys.find((key) => key.toLowerCase() === filter.toLowerCase()) ?? null; + const searchableKeys = [...routeKeys, 'IconSheet', ...pinnedRouteKeys]; + return searchableKeys.find((key) => key.toLowerCase() === filter.toLowerCase()) ?? null; }, [isOpen, filter, routeKeys]); const navigate = useCallback( @@ -55,15 +57,21 @@ export function ExamplesListScreen({ route }: ExamplesListScreenProps) { ); const data = useMemo(() => { - const sorted = [...routeKeys, 'IconSheet'].sort().filter((key) => key !== 'Examples'); - - if (!isOpen || filter === '') return sorted; - - return sorted.filter((key) => { + const filterBySearch = (key: string) => { + if (!isOpen || filter === '') return true; // Exclude the exact match from the list — it's shown as the shortcut button above. if (exactMatch && key === exactMatch) return false; return key.toLowerCase().includes(filter.toLowerCase()); - }); + }; + + const pinnedData = pinnedRouteKeys.filter(filterBySearch); + const sortedData = [...routeKeys, 'IconSheet'] + .sort() + .filter((key) => key !== 'Examples') + .filter((key) => !pinnedRouteKeys.includes(key)) + .filter(filterBySearch); + + return [...pinnedData, ...sortedData]; }, [routeKeys, isOpen, filter, exactMatch]); return ( diff --git a/apps/expo-app/src/playground/Playground.tsx b/apps/expo-app/src/playground/Playground.tsx index e149df74a3..b67454dcef 100644 --- a/apps/expo-app/src/playground/Playground.tsx +++ b/apps/expo-app/src/playground/Playground.tsx @@ -12,6 +12,7 @@ import { Text } from '@coinbase/cds-mobile/typography/Text'; import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { CustomerComponentConfigScreen } from './CustomerComponentConfigScreen'; import { ExamplesListScreen } from './ExamplesListScreen'; import { ExamplesSearchProvider, SearchContext } from './ExamplesSearchProvider'; import { IconSheetScreen } from './IconSheetScreen'; @@ -243,6 +244,11 @@ const PlaygroundContent = memo( name="DebugIconSheet" options={{ title: 'Icon Sheet' }} /> + {exampleScreens} ); diff --git a/apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx b/apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx new file mode 100644 index 0000000000..6fa1f85497 --- /dev/null +++ b/apps/expo-app/src/playground/customerComponentConfig/ComponentConfigComparison.tsx @@ -0,0 +1,52 @@ +import React, { memo, useCallback, useState } from 'react'; +import type { ComponentConfig } from '@coinbase/cds-mobile/core/componentConfig'; +import { Switch } from '@coinbase/cds-mobile/controls/Switch'; +import { HStack } from '@coinbase/cds-mobile/layout/HStack'; +import { VStack } from '@coinbase/cds-mobile/layout/VStack'; +import { ComponentConfigProvider } from '@coinbase/cds-mobile/system/ComponentConfigProvider'; +import { Text } from '@coinbase/cds-mobile/typography/Text'; + +const emptyConfig: ComponentConfig = {}; + +export type ComponentConfigComparisonProps = { + componentName: string; + /** + * Renders the component under test. Invoked for whichever mode is active + * (default CDS or customer-configured) so each mode gets its own tree. + */ + children: () => React.ReactNode; +}; + +/** + * Toggle orchestration for customer component config work. + * Place inside a screen-level {@link ComponentConfigProvider} with the customer config; + * Default mode isolates itself with an empty config scope. + */ +export const ComponentConfigComparison = memo( + ({ componentName, children }: ComponentConfigComparisonProps) => { + const [showConfigured, setShowConfigured] = useState(false); + + const handleToggle = useCallback((_: string | undefined, checked?: boolean) => { + setShowConfigured(Boolean(checked)); + }, []); + + return ( + + + {componentName} + {/* Keep the mode toggle on stock CDS so customer Switch config can't restyle it. */} + + + {showConfigured ? 'Configured' : 'Default'} + + + + {showConfigured ? ( + children() + ) : ( + {children()} + )} + + ); + }, +); diff --git a/apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts b/apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts new file mode 100644 index 0000000000..804a9dddb9 --- /dev/null +++ b/apps/expo-app/src/playground/customerComponentConfig/customerComponentConfig.ts @@ -0,0 +1,7 @@ +import type { ComponentConfig } from '@coinbase/cds-mobile/core/componentConfig'; + +/** + * Customer component config under test. + * Add per-component resolvers here as we configure them. + */ +export const customerComponentConfig: ComponentConfig = {}; diff --git a/apps/expo-app/src/playground/types.ts b/apps/expo-app/src/playground/types.ts index 9b543f2b56..9e15fea666 100644 --- a/apps/expo-app/src/playground/types.ts +++ b/apps/expo-app/src/playground/types.ts @@ -5,6 +5,7 @@ type RouteParams = { routeKeys: string[] } | undefined; export type PlaygroundStackParamList = { DebugExamples: { routeKeys: string[] }; DebugIconSheet: undefined; + DebugCustomerComponentConfig: undefined; } & { [key: string]: RouteParams; }; From 5612d3d9befa76863dec22a9ebd64469fa07adea Mon Sep 17 00:00:00 2001 From: Erich Kuerschner Date: Mon, 10 Aug 2026 16:54:18 -0500 Subject: [PATCH 2/6] feat: build in affordances for specific customizations in: Accordion, Tabs, SegmentedTabs and add more style customization options to some other components --- .../layout/Accordion/_webStyles.mdx | 25 + .../components/layout/Accordion/index.mdx | 3 + .../layout/AccordionItem/_mobileStyles.mdx | 7 + .../layout/AccordionItem/_webStyles.mdx | 25 + .../components/layout/AccordionItem/index.mdx | 6 + .../SegmentedTabs/_mobileExamples.mdx | 25 + .../navigation/SegmentedTabs/_webExamples.mdx | 25 + .../CustomerComponentConfigScreen.tsx | 217 ++++++- .../src/playground/ExamplesListScreen.tsx | 6 +- apps/expo-app/src/playground/Playground.tsx | 2 +- .../ComponentConfigComparison.tsx | 36 +- .../customerComponentConfig.ts | 35 +- .../customerComponentConfig/retailCDSTheme.ts | 535 ++++++++++++++++++ packages/mobile/src/accordion/Accordion.tsx | 36 +- .../mobile/src/accordion/AccordionHeader.tsx | 76 ++- .../mobile/src/accordion/AccordionItem.tsx | 127 +++-- .../mobile/src/accordion/AccordionPanel.tsx | 9 +- .../accordion/__tests__/Accordion.test.tsx | 200 +++++++ .../mobile/src/collapsible/Collapsible.tsx | 15 +- packages/mobile/src/core/componentConfig.ts | 2 + packages/mobile/src/tabs/SegmentedTabs.tsx | 34 +- .../src/tabs/__tests__/SegmentedTabs.test.tsx | 22 + packages/web/src/accordion/Accordion.tsx | 63 ++- .../web/src/accordion/AccordionHeader.tsx | 78 ++- packages/web/src/accordion/AccordionItem.tsx | 135 +++-- packages/web/src/accordion/AccordionPanel.tsx | 9 +- .../accordion/__tests__/Accordion.test.tsx | 168 +++++- packages/web/src/collapsible/Collapsible.tsx | 16 +- packages/web/src/core/componentConfig.ts | 2 + packages/web/src/tabs/SegmentedTabs.tsx | 45 +- .../src/tabs/__tests__/SegmentedTabs.test.tsx | 50 ++ 31 files changed, 1887 insertions(+), 147 deletions(-) create mode 100644 apps/docs/docs/components/layout/Accordion/_webStyles.mdx create mode 100644 apps/docs/docs/components/layout/AccordionItem/_mobileStyles.mdx create mode 100644 apps/docs/docs/components/layout/AccordionItem/_webStyles.mdx create mode 100644 apps/expo-app/src/playground/customerComponentConfig/retailCDSTheme.ts diff --git a/apps/docs/docs/components/layout/Accordion/_webStyles.mdx b/apps/docs/docs/components/layout/Accordion/_webStyles.mdx new file mode 100644 index 0000000000..6f2b198d41 --- /dev/null +++ b/apps/docs/docs/components/layout/Accordion/_webStyles.mdx @@ -0,0 +1,25 @@ +import { Accordion } from '@coinbase/cds-web/accordion/Accordion'; +import { AccordionItem } from '@coinbase/cds-web/accordion/AccordionItem'; +import { Text } from '@coinbase/cds-web/typography/Text'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; + +import webStylesData from ':docgen/web/accordion/Accordion/styles-data'; + +## Explorer + + + {(classNames) => ( + + + + Accordion panel content + + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/layout/Accordion/index.mdx b/apps/docs/docs/components/layout/Accordion/index.mdx index 2f862c5650..4fec2ae440 100644 --- a/apps/docs/docs/components/layout/Accordion/index.mdx +++ b/apps/docs/docs/components/layout/Accordion/index.mdx @@ -17,6 +17,7 @@ import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import webMetadata from './webMetadata.json'; import mobileMetadata from './mobileMetadata.json'; @@ -26,11 +27,13 @@ import mobileMetadata from './mobileMetadata.json'; } webExamples={} + webStyles={} mobilePropsTable={} mobileExamples={} webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} mobilePropsToc={mobilePropsToc} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/layout/AccordionItem/_mobileStyles.mdx b/apps/docs/docs/components/layout/AccordionItem/_mobileStyles.mdx new file mode 100644 index 0000000000..f6ec48574f --- /dev/null +++ b/apps/docs/docs/components/layout/AccordionItem/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/accordion/AccordionItem/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/layout/AccordionItem/_webStyles.mdx b/apps/docs/docs/components/layout/AccordionItem/_webStyles.mdx new file mode 100644 index 0000000000..3813de5376 --- /dev/null +++ b/apps/docs/docs/components/layout/AccordionItem/_webStyles.mdx @@ -0,0 +1,25 @@ +import { Accordion } from '@coinbase/cds-web/accordion/Accordion'; +import { AccordionItem } from '@coinbase/cds-web/accordion/AccordionItem'; +import { Text } from '@coinbase/cds-web/typography/Text'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; + +import webStylesData from ':docgen/web/accordion/AccordionItem/styles-data'; + +## Explorer + + + {(classNames) => ( + + + + Accordion panel content + + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/layout/AccordionItem/index.mdx b/apps/docs/docs/components/layout/AccordionItem/index.mdx index 3b2d68695f..efa39730e1 100644 --- a/apps/docs/docs/components/layout/AccordionItem/index.mdx +++ b/apps/docs/docs/components/layout/AccordionItem/index.mdx @@ -17,6 +17,8 @@ import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import webMetadata from './webMetadata.json'; import mobileMetadata from './mobileMetadata.json'; @@ -30,11 +32,15 @@ import mobileMetadata from './mobileMetadata.json'; } webExamples={} + webStyles={} mobilePropsTable={} mobileExamples={} + mobileStyles={} webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} mobilePropsToc={mobilePropsToc} + webStylesToc={webStylesToc} + mobileStylesToc={mobileStylesToc} /> diff --git a/apps/docs/docs/components/navigation/SegmentedTabs/_mobileExamples.mdx b/apps/docs/docs/components/navigation/SegmentedTabs/_mobileExamples.mdx index c800395624..fee69b62a2 100644 --- a/apps/docs/docs/components/navigation/SegmentedTabs/_mobileExamples.mdx +++ b/apps/docs/docs/components/navigation/SegmentedTabs/_mobileExamples.mdx @@ -150,6 +150,31 @@ function Example() { } ``` +### Equal Width + +Set `equalWidth` to make each tab stretch to an equal share of the available width, with the component filling its parent. This is the correct way to achieve a distributed tab layout — avoid using `justifyContent` for this, as it breaks the active indicator's position calculation. + +```jsx +function Example() { + const tabs = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell' }, + { id: 'convert', label: 'Convert' }, + ]; + const [activeTab, updateActiveTab] = useState(tabs[0]); + const handleChange = useCallback((activeTab) => updateActiveTab(activeTab), []); + return ( + + ); +} +``` + ### Colors You can set `color` and `activeColor` to change the color of the labels. diff --git a/apps/docs/docs/components/navigation/SegmentedTabs/_webExamples.mdx b/apps/docs/docs/components/navigation/SegmentedTabs/_webExamples.mdx index d5be502dbb..db33e03d5b 100644 --- a/apps/docs/docs/components/navigation/SegmentedTabs/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/SegmentedTabs/_webExamples.mdx @@ -149,6 +149,31 @@ function Example() { } ``` +### Equal Width + +Set `equalWidth` to make each tab stretch to an equal share of the available width. This also defaults `width` to `100%` so the component fills its parent — pass an explicit `width` to override this. This is the correct way to achieve a distributed tab layout — avoid using `justifyContent` for this, as it breaks the active indicator's position calculation. + +```jsx live +function Example() { + const tabs = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell' }, + { id: 'convert', label: 'Convert' }, + ]; + const [activeTab, updateActiveTab] = useState(tabs[0]); + const handleChange = useCallback((activeTab) => updateActiveTab(activeTab), []); + return ( + + ); +} +``` + ### Colors You can set `color` and `activeColor` to change the color of the labels. diff --git a/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx b/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx index 8e62f5b6c8..9f704e3c4c 100644 --- a/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx +++ b/apps/expo-app/src/playground/CustomerComponentConfigScreen.tsx @@ -1,27 +1,224 @@ -import React, { memo } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { ScrollView } from 'react-native'; -import { Button } from '@coinbase/cds-mobile/buttons/Button'; +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 { Switch } from '@coinbase/cds-mobile/controls/Switch'; +import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'; +import { DateInput } from '@coinbase/cds-mobile/dates/DateInput'; +import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { Divider } from '@coinbase/cds-mobile/layout'; +import { Box } from '@coinbase/cds-mobile/layout/Box'; +import { HStack } from '@coinbase/cds-mobile/layout/HStack'; import { VStack } from '@coinbase/cds-mobile/layout/VStack'; import { ComponentConfigProvider } from '@coinbase/cds-mobile/system/ComponentConfigProvider'; +import { ThemeProvider } from '@coinbase/cds-mobile/system/ThemeProvider'; +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 { ComponentConfigComparison } from './customerComponentConfig/ComponentConfigComparison'; import { customerComponentConfig } from './customerComponentConfig/customerComponentConfig'; +import { retailCDSTheme } from './customerComponentConfig/retailCDSTheme'; + +const scrollContentContainerStyle = { flexGrow: 1 }; + +const tabsExampleData: TabValue[] = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell' }, +]; + +/** Self-contained Tabs example so its active-tab state lives outside the render-prop callback. */ +const TabsExample = memo(() => { + const [activeTab, setActiveTab] = useState(tabsExampleData[0]); + return ( + + + + + ); +}); + +const segmentedTabsExampleData: TabValue[] = [ + { id: 'home', label: 'Home' }, + { id: 'trade', label: 'Trade' }, + { id: 'account', label: 'Account' }, +]; + +/** Self-contained SegmentedTabs example so its active-tab state lives outside the render-prop callback. */ +const SegmentedTabsExample = memo(() => { + const [activeTab, setActiveTab] = useState(segmentedTabsExampleData[0]); + return ( + + ); +}); + +const comparisonKeys = ['Accordion', 'Inputs', 'Tabs', 'SegmentedTabs'] as const; +type ComparisonKey = (typeof comparisonKeys)[number]; + +const initialConfiguredState: Record = { + Accordion: false, + Inputs: false, + Tabs: false, + SegmentedTabs: false, +}; /** * Playground screen for iterating on a customer's CDS component config. * Wrap examples in {@link ComponentConfigComparison} to toggle stock CDS vs configured. */ export const CustomerComponentConfigScreen = memo(() => { + const ambientTheme = useTheme(); + const [useRetailTheme, setUseRetailTheme] = useState(false); + const [configuredState, setConfiguredState] = useState(initialConfiguredState); + + const handleToggleRetailTheme = useCallback((_: string | undefined, checked?: boolean) => { + setUseRetailTheme(Boolean(checked)); + }, []); + + const setConfigured = 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 handleToggleAllConfigured = useCallback((_: string | undefined, checked?: boolean) => { + const nextChecked = Boolean(checked); + setConfiguredState( + comparisonKeys.reduce( + (next, key) => ({ ...next, [key]: nextChecked }), + {} as Record, + ), + ); + }, []); + return ( - - - {/* Placeholder: both modes match until customerComponentConfig defines Button. */} - - {() => } - - - + + + {/* Always mount ThemeProvider (swapping only the `theme` value) so this subtree never + remounts on toggle, preserving the local state of every control beneath it. Nesting + the theme toggle itself inside the provider lets the retail theme restyle its own + switch too. */} + + + + + Apply Retail theme + + + + + Toggle all + + + + + + + {() => ( + + + Accordion panel content. + + + Accordion panel content 2. + + + )} + + + {() => ( + + +