diff --git a/apps/docs/docs/components/inputs/SlideButton/_mobileExamples.mdx b/apps/docs/docs/components/inputs/SlideButton/_mobileExamples.mdx index a6efccf54..64e3c8973 100644 --- a/apps/docs/docs/components/inputs/SlideButton/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/SlideButton/_mobileExamples.mdx @@ -1,9 +1,9 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; import ThemedImage from '@theme/ThemedImage'; -### SlideButton +## Basics -Use the `onChange` prop to listen and make changes to the `checked` state. +Use the `onChange` callback to update the `checked` state. This is the primary callback that controls both the visual and accessible state of the component. console.log('Completed')} uncheckedLabel="Swipe to confirm" checkedLabel="Confirming..." /> @@ -33,9 +32,9 @@ function Example() { } ``` -### Negative SlideButton +## Variants -You can use the `variant` prop to change the color of the button. +Use the `variant` prop to change the color of the button. The default variant is `primary`. Available variants are `negative` and `positive`. console.log('Completed')} uncheckedLabel="Swipe to confirm" checkedLabel="Confirming..." variant="negative" @@ -66,7 +64,7 @@ function Example() { } ``` -### Compact SlideButton +## Compact Use the `compact` prop to reduce the height, border-radius and padding of the button: @@ -78,7 +76,6 @@ function Example() { console.log('Completed')} uncheckedLabel="Swipe to confirm" checkedLabel="Confirming..." compact @@ -87,9 +84,31 @@ function Example() { } ``` -### Auto Complete on Threshold +## Disabled + +Use the `disabled` prop to prevent interaction. This works for both unchecked and checked states. + +```jsx +function Example() { + return ( + + + + + ); +} +``` + +## Auto Complete on Threshold -You can set the button to automatically complete when the slide reaches the threshold: +By default, the user must release the handle past the threshold to complete. Set `autoCompleteSlideOnThresholdMet` to automatically complete as soon as the threshold is reached, without requiring release. + +You can also adjust the threshold via `checkThreshold` (a value from 0 to 1, defaulting to 0.7). ```jsx function Example() { @@ -99,7 +118,6 @@ function Example() { console.log('Completed')} uncheckedLabel="Swipe to confirm" checkedLabel="Confirming..." autoCompleteSlideOnThresholdMet @@ -108,9 +126,41 @@ function Example() { } ``` -### Custom Nodes on SlideButton +## Callback Lifecycle + +SlideButton fires callbacks in a specific order during the slide gesture: + +1. `onSlideStart` -- when the gesture begins +2. `onChange` -- when the slide completes past the threshold (sets `checked` to `true`) +3. `onSlideComplete` -- immediately after `onChange` +4. `onSlideEnd` -- always fires last + +If the user releases before the threshold, `onSlideCancel` fires instead, followed by `onSlideEnd`. + +**Important:** Always use `onChange` to manage the `checked` state. The `checked` prop drives the component's `accessibilityLabel` (switching between `uncheckedLabel` and `checkedLabel`), so failing to update it means screen readers won't announce the state change. Use `onSlideComplete` only for supplementary side effects (e.g. analytics, haptic feedback) that don't affect accessible state. + +```jsx +function Example() { + const [checked, setChecked] = useState(false); + + return ( + console.log('Started')} + onSlideComplete={() => console.log('Completed')} + onSlideCancel={() => console.log('Cancelled')} + onSlideEnd={() => console.log('Ended')} + uncheckedLabel="Swipe to confirm" + checkedLabel="Confirming..." + /> + ); +} +``` + +## Custom Nodes -You can also use SlideButton with custom nodes. +Use `startUncheckedNode` and `endCheckedNode` to replace the default arrow icon and loading indicator on the handle. ```jsx @@ -132,19 +182,41 @@ function Example() { console.log('Completed')} uncheckedLabel="Swipe to enable notifications" checkedLabel="Enabling..." - startUncheckedNode={} - endCheckedNode={} + startUncheckedNode={} + endCheckedNode={} /> ); } ``` -### Custom Background and Handle Components +## Labels as Nodes + +The `uncheckedLabel` and `checkedLabel` props accept `ReactNode`, so you can pass custom styled text or other components. When using non-string labels, the component uses `accessibilityLabelledBy` to associate the handle with the container element, so ensure your label nodes contain meaningful text content. -You can customize the background and handle components of the SlideButton. +```jsx +function Example() { + const [checked, setChecked] = useState(false); + + return ( + Swipe to confirm} + checkedLabel={ + + Confirming... + + } + /> + ); +} +``` + +## Custom Background and Handle Components + +You can fully customize the background and handle by providing your own components via `SlideButtonBackgroundComponent` and `SlideButtonHandleComponent`. Your components receive typed props (`SlideButtonBackgroundProps` and `SlideButtonHandleProps`) including a `progress` spring value and the current `checked` state. ```jsx @@ -208,3 +280,28 @@ function Example() { ); } ``` + +## Accessibility + +SlideButton has built-in accessibility support. The component automatically derives its `accessibilityLabel` from the `checked` state -- displaying `uncheckedLabel` when unchecked and `checkedLabel` when checked. It also registers an `activate` accessibility action so screen readers can trigger the slide without performing a gesture. + +**Use `onChange` as your primary callback.** The `onChange` callback updates the `checked` prop, which controls the accessible label. Placing critical logic in `onSlideComplete` without updating `checked` via `onChange` will leave the accessible state stale, meaning screen readers won't announce the confirmation. + +When providing a custom `SlideButtonHandleComponent`, always spread the incoming props to preserve the built-in `accessibilityActions` and `onAccessibilityAction` handlers, and set `accessibilityLabel` and `accessibilityRole="button"` on the handle element. + +When using `ReactNode` labels instead of strings, the component uses `accessibilityLabelledBy` to link to the container element, so ensure your custom label nodes contain meaningful text. + +```jsx +function Example() { + const [checked, setChecked] = useState(false); + + return ( + + ); +} +``` diff --git a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json index 31631d3ce..2c8b9fc38 100644 --- a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json @@ -21,5 +21,10 @@ "url": "/components/inputs/Pressable/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + } + ] } diff --git a/apps/docs/docs/getting-started/installation/_mobileContent.mdx b/apps/docs/docs/getting-started/installation/_mobileContent.mdx index a187b381f..05d006706 100644 --- a/apps/docs/docs/getting-started/installation/_mobileContent.mdx +++ b/apps/docs/docs/getting-started/installation/_mobileContent.mdx @@ -5,6 +5,10 @@ import { MDXArticle } from '@site/src/components/page/MDXArticle'; ## Installation +:::tip Starting a new project? +Check out our [templates](/getting-started/templates) for pre-configured starter apps with CDS already set up. +::: + To install the CDS library for React Native applications, run the following command: ```bash diff --git a/apps/docs/docs/getting-started/installation/_webContent.mdx b/apps/docs/docs/getting-started/installation/_webContent.mdx index 0144be0c8..bf4873b6e 100644 --- a/apps/docs/docs/getting-started/installation/_webContent.mdx +++ b/apps/docs/docs/getting-started/installation/_webContent.mdx @@ -5,6 +5,10 @@ import { MDXArticle } from '@site/src/components/page/MDXArticle'; ## Installation +:::tip Starting a new project? +Check out our [templates](/getting-started/templates) for pre-configured starter apps with CDS already set up. +::: + To install the CDS library for React web applications, run the following command: ```bash diff --git a/apps/docs/docs/getting-started/templates/_mobileContent.mdx b/apps/docs/docs/getting-started/templates/_mobileContent.mdx new file mode 100644 index 000000000..5798d8de8 --- /dev/null +++ b/apps/docs/docs/getting-started/templates/_mobileContent.mdx @@ -0,0 +1,105 @@ +import { MDXSection } from '@site/src/components/page/MDXSection'; +import { MDXArticle } from '@site/src/components/page/MDXArticle'; +import { TemplateCard } from '@site/src/components/page/TemplateCard'; +import { HStack } from '@coinbase/cds-web/layout'; +import ThemedImage from '@theme/ThemedImage'; + + + + +## Get started + +The easiest way to get started with CDS on mobile is with a template. The Expo template includes the required CDS packages, dependencies, and pre-configured settings with a working example application to help you start building. + + + + } + /> + + + + + + + + +## Installation + +To create a new project from the template, use `gitpick` to bootstrap your application: + +### Expo + +```bash +npx -y gitpick coinbase/cds/tree/master/templates/expo-app cds-expo +cd cds-expo +``` + + + + + + + +## Setup + +After creating your project, install dependencies and start developing: + +```bash +# We suggest using nvm to manage Node.js versions +nvm install +nvm use + +# Enable corepack for package manager setup +corepack enable + +# Install dependencies +yarn + +# Start development server +yarn start +``` + + + + + + + +## What's included + +All templates come pre-configured with: + +- Latest CDS packages (`@coinbase/cds-mobile`, `@coinbase/cds-icons`, etc.) +- TypeScript configuration +- Example components demonstrating common UI patterns +- Theme setup with CDS default theme +- Navigation with React Navigation + + + + + + + +## Next steps + +After setting up your template, learn how to customize and extend CDS: + +- [Theming](/getting-started/theming) - Customize colors, spacing, and typography +- [Installation](/getting-started/installation) - Manual installation and setup options + + + diff --git a/apps/docs/docs/getting-started/templates/index.mdx b/apps/docs/docs/getting-started/templates/index.mdx index c126ec318..821b28e69 100644 --- a/apps/docs/docs/getting-started/templates/index.mdx +++ b/apps/docs/docs/getting-started/templates/index.mdx @@ -1,7 +1,7 @@ --- id: templates title: Templates -platform_switcher_options: { web: true, mobile: false } +platform_switcher_options: { web: true, mobile: true } hide_title: true --- @@ -11,10 +11,17 @@ import { ContentHeader } from '@site/src/components/page/ContentHeader'; import { ContentPageContainer } from '@site/src/components/page/ContentPageContainer'; import WebContent, { toc as webContentToc } from './_webContent.mdx'; +import MobileContent, { toc as mobileContentToc } from './_mobileContent.mdx'; import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; - - } webContentToc={webContentToc} /> + + } + webContentToc={webContentToc} + mobileContent={} + mobileContentToc={mobileContentToc} + /> diff --git a/apps/docs/docs/getting-started/templates/mobileMetadata.json b/apps/docs/docs/getting-started/templates/mobileMetadata.json new file mode 100644 index 000000000..da19f1de1 --- /dev/null +++ b/apps/docs/docs/getting-started/templates/mobileMetadata.json @@ -0,0 +1,3 @@ +{ + "description": "Get started quickly with a pre-built Expo template configured with CDS components and best practices." +} diff --git a/apps/docs/static/img/logos/frameworks/expo_dark.png b/apps/docs/static/img/logos/frameworks/expo_dark.png new file mode 100644 index 000000000..52ae390f3 Binary files /dev/null and b/apps/docs/static/img/logos/frameworks/expo_dark.png differ diff --git a/apps/docs/static/img/logos/frameworks/expo_light.png b/apps/docs/static/img/logos/frameworks/expo_light.png new file mode 100644 index 000000000..ef1c5458b Binary files /dev/null and b/apps/docs/static/img/logos/frameworks/expo_light.png differ diff --git a/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx b/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx index e538ae0e9..26597b66f 100644 --- a/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx +++ b/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx @@ -5,9 +5,20 @@ figma.connect( Carousel, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=48671-10433', { + variant: { platform: 'mobile' }, imports: ["import { Carousel, CarouselItem } from '@coinbase/cds-mobile/carousel'"], - example: () => ( - + props: { + title: figma.boolean('show header', { + true: figma.string('title'), + false: undefined, + }), + hidePagination: figma.boolean('show pagination', { + true: undefined, + false: true, + }), + }, + example: ({ title, hidePagination }) => ( + {/* Item content */} {/* Item content */} {/* Item content */} diff --git a/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx b/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx index dd92e1c8a..3406b6f83 100644 --- a/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx +++ b/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx @@ -1,46 +1,47 @@ -import { useRef, useState } from 'react'; +import React, { useState } from 'react'; import { figma } from '@figma/code-connect'; import { Button } from '../../../buttons/Button'; import { Box, VStack } from '../../../layout'; -import { TextBody, TextTitle1 } from '../../../typography'; -import { Tray, TrayStickyFooter } from '../Tray'; +import { StickyFooter } from '../../../sticky-footer/StickyFooter'; +import { Text } from '../../../typography/Text'; +import { Tray } from '../Tray'; figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-33327&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'"], + imports: [ + "import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'", + "import { StickyFooter } from '@coinbase/cds-mobile/sticky-footer/StickyFooter'", + ], props: { title: figma.boolean('show section header', { true: figma.textContent('SectionHeader'), false: undefined, }), - stickyFooter: figma.children('StickyFooter'), content: figma.children('.Select Option*'), }, - example: function TrayExample({ stickyFooter, content, title }) { + example: function TrayExample({ content, title }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( ( + + + + )} + handleBarVariant="inside" onCloseComplete={() => setIsTrayVisible(false)} - onVisibilityChange={() => {}} title={title} > - {({ handleClose }) => ( - - {content} - {stickyFooter} - - )} + {content} )} @@ -59,37 +60,31 @@ figma.connect( true: figma.children('Spot Square/blockchain'), false: undefined, }), - title: figma.textContent('SectionHeader'), - stickyFooter: figma.children('StickyFooter'), + sectionTitle: figma.textContent('SectionHeader'), }, - example: function TrayExample({ pictogram, title, stickyFooter }) { + example: function TrayExample({ pictogram, sectionTitle }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( setIsTrayVisible(false)} - onVisibilityChange={() => {}} - title={title} - > - {({ handleClose }) => ( - + title={ + {pictogram} - - Lorem ipsum dolor sit amet consectetur. Lacus vitae vulputate maecenas sed ac - cursus enim elementum euismod. Ac vulputate gravida mauris id nulla imperdiet - eget. Dictum vitae enim eget ut. Maecenas hendrerit amet integer sagittis cras. - Fermentum ultricies malesuada interdum - - {stickyFooter} - - )} + {sectionTitle} + + } + > + + + Content goes here. + + )} @@ -102,44 +97,45 @@ figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-33505&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'"], + imports: [ + "import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'", + "import { StickyFooter } from '@coinbase/cds-mobile/sticky-footer/StickyFooter'", + ], props: { spotRectangle: figma.instance('spot rectangle'), title: figma.string('title'), body: figma.string('body'), - stickyFooter: figma.children('StickyFooter'), }, - example: function TrayExample({ spotRectangle, title, body, stickyFooter }) { + example: function TrayExample({ spotRectangle, title, body }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( ( + + + + )} + handleBarVariant="inside" onCloseComplete={() => setIsTrayVisible(false)} - onVisibilityChange={() => {}} > - {({ handleClose }) => ( - - - - {spotRectangle} - - - {title} - - - {body} - - - {stickyFooter} - - )} + + + {spotRectangle} + + + {title} + + + {body} + + )} @@ -158,20 +154,17 @@ figma.connect( }, example: function TrayExample({ children }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( setIsTrayVisible(false)} - onVisibilityChange={() => {}} + title="Title" > - {({ handleClose }) => {children}} + {children} )} @@ -184,37 +177,37 @@ figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-77780&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'"], + imports: [ + "import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'", + "import { StickyFooter } from '@coinbase/cds-mobile/sticky-footer/StickyFooter'", + ], props: { content: figma.instance('content'), - stickyFooter: figma.children('StickyFooter'), title: figma.boolean('show section header', { true: figma.textContent('SectionHeader'), false: undefined, }), }, - example: function TrayExample({ content, stickyFooter, title }) { + example: function TrayExample({ content, title }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( ( + + + + )} + handleBarVariant="inside" onCloseComplete={() => setIsTrayVisible(false)} - onVisibilityChange={() => {}} title={title} > - {({ handleClose }) => ( - - {content} - {stickyFooter} - - )} + {content} )} diff --git a/packages/web/src/carousel/__figma__/Carousel.figma.tsx b/packages/web/src/carousel/__figma__/Carousel.figma.tsx index b54847416..cb248f543 100644 --- a/packages/web/src/carousel/__figma__/Carousel.figma.tsx +++ b/packages/web/src/carousel/__figma__/Carousel.figma.tsx @@ -6,9 +6,20 @@ figma.connect( Carousel, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=48671-10433', { + variant: { platform: 'desktop' }, imports: ["import { Carousel, CarouselItem } from '@coinbase/cds-web/carousel'"], - example: () => ( - + props: { + title: figma.boolean('show header', { + true: figma.string('title'), + false: undefined, + }), + hidePagination: figma.boolean('show pagination', { + true: undefined, + false: true, + }), + }, + example: ({ title, hidePagination }) => ( + {/* Item content */} {/* Item content */} {/* Item content */} diff --git a/packages/web/src/overlays/tray/__figma__/Tray.figma.tsx b/packages/web/src/overlays/tray/__figma__/Tray.figma.tsx new file mode 100644 index 000000000..59633c6c2 --- /dev/null +++ b/packages/web/src/overlays/tray/__figma__/Tray.figma.tsx @@ -0,0 +1,165 @@ +import { useId, useState } from 'react'; +import { figma } from '@figma/code-connect'; + +import { Button } from '../../../buttons'; +import { useBreakpoints } from '../../../hooks/useBreakpoints'; +import { Pictogram } from '../../../illustrations/Pictogram'; +import { Box } from '../../../layout'; +import { VStack } from '../../../layout/VStack'; +import { PageFooter } from '../../../page/PageFooter'; +import { Text } from '../../../typography/Text'; +import { Tray } from '../Tray'; + +const FIGMA_URL = + 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=74148-11495&m=dev'; + +figma.connect(Tray, FIGMA_URL, { + variant: { type: 'standard' }, + imports: [ + "import { Tray } from '@coinbase/cds-web/overlays/tray/Tray'", + "import { PageFooter } from '@coinbase/cds-web/page/PageFooter'", + "import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'", + ], + props: { + title: figma.textContent('SectionHeader'), + }, + example: function StandardExample({ title }) { + const [visible, setVisible] = useState(false); + const { isPhone } = useBreakpoints(); + return ( + <> + + {visible && ( + ( + + Close + + } + justifyContent={isPhone ? 'center' : 'flex-end'} + /> + )} + onCloseComplete={() => setVisible(false)} + pin={isPhone ? 'bottom' : 'right'} + showHandleBar={isPhone} + title={title} + > + + Content goes here. + + + )} + + ); + }, +}); + +figma.connect(Tray, FIGMA_URL, { + variant: { type: 'illustration' }, + imports: [ + "import { Tray } from '@coinbase/cds-web/overlays/tray/Tray'", + "import { Pictogram } from '@coinbase/cds-web/illustrations/Pictogram'", + "import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'", + ], + props: { + sectionTitle: figma.textContent('SectionHeader'), + }, + example: function IllustrationExample({ sectionTitle }) { + const [visible, setVisible] = useState(false); + const { isPhone } = useBreakpoints(); + const titleId = useId(); + return ( + <> + + {visible && ( + setVisible(false)} + pin={isPhone ? 'bottom' : 'right'} + showHandleBar={isPhone} + title={ + + + + {sectionTitle} + + + } + > + + Content goes here. + + + )} + + ); + }, +}); + +figma.connect(Tray, FIGMA_URL, { + variant: { type: 'full-bleed image' }, + imports: [ + "import { Tray } from '@coinbase/cds-web/overlays/tray/Tray'", + "import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'", + ], + props: { + sectionTitle: figma.textContent('SectionHeader'), + }, + example: function FullBleedImageExample({ sectionTitle }) { + const [visible, setVisible] = useState(false); + const { isPhone } = useBreakpoints(); + const titleId = useId(); + return ( + <> + + {visible && ( + + {sectionTitle} + + } + onCloseComplete={() => setVisible(false)} + pin={isPhone ? 'bottom' : 'right'} + showHandleBar={isPhone} + styles={{ + handleBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + }, + closeButton: { + position: 'absolute', + top: 'var(--space-4)', + right: 'var(--space-4)', + zIndex: 1, + }, + header: { paddingTop: 0 }, + }} + title={ + + Full Bleed + + } + > + + Content goes here. + + + )} + + ); + }, +}); diff --git a/templates/expo-app/.gitignore b/templates/expo-app/.gitignore new file mode 100644 index 000000000..319aa00b9 --- /dev/null +++ b/templates/expo-app/.gitignore @@ -0,0 +1,33 @@ +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo diff --git a/templates/expo-app/.nvmrc b/templates/expo-app/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/templates/expo-app/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/templates/expo-app/.yarnrc.yml b/templates/expo-app/.yarnrc.yml new file mode 100644 index 000000000..c3ff0bb6b --- /dev/null +++ b/templates/expo-app/.yarnrc.yml @@ -0,0 +1,4 @@ +nodeLinker: node-modules + + + diff --git a/templates/expo-app/App.tsx b/templates/expo-app/App.tsx new file mode 100644 index 000000000..55e97a1df --- /dev/null +++ b/templates/expo-app/App.tsx @@ -0,0 +1,137 @@ +import React, { memo, useState, useCallback } from 'react'; +import { ScrollView } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useFonts } from 'expo-font'; +import { Inter_400Regular, Inter_600SemiBold } from '@expo-google-fonts/inter'; + +import type { ColorScheme } from '@coinbase/cds-common'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; + +import { useTheme } from '@coinbase/cds-mobile'; +import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; +import { VStack, HStack, Box } from '@coinbase/cds-mobile/layout'; +import { TabbedChips } from '@coinbase/cds-mobile/alpha/tabbed-chips/TabbedChips'; +import { PortalProvider } from '@coinbase/cds-mobile/overlays/PortalProvider'; +import { StatusBar, ThemeProvider } from '@coinbase/cds-mobile/system'; + +import { Navbar } from './components/Navbar'; +import { AssetList } from './components/AssetList'; +import { CardList } from './components/CardList'; +import { AssetCarousel } from './components/AssetCarousel'; +import { AssetChart } from './components/AssetChart'; +import { TabBarButton } from './components/TabBarButton'; + +const chipTabs = [ + { id: 'all', label: 'All' }, + { id: 'crypto', label: 'Crypto' }, + { id: 'nfts', label: 'NFTs' }, + { id: 'defi', label: 'DeFi' }, + { id: 'earn', label: 'Earn' }, +]; + +const CdsSafeAreaProvider = memo(({ children }: React.PropsWithChildren) => { + const theme = useTheme(); + return ( + {children} + ); +}); + +export default function App() { + const [activeColorScheme, setActiveColorScheme] = useState('light'); + const [fontsLoaded] = useFonts({ + CoinbaseIcons: require('@coinbase/cds-icons/fonts/native/CoinbaseIcons.ttf'), + Inter_400Regular, + Inter_600SemiBold, + }); + + const toggleColorScheme = useCallback( + () => setActiveColorScheme((s) => (s === 'light' ? 'dark' : 'light')), + [], + ); + + if (!fontsLoaded) { + return null; + } + + return ( + + + + + + + + + + + ); +} + +function AppContent({ toggleColorScheme }: { toggleColorScheme: () => void }) { + const [activeChip, setActiveChip] = useState(chipTabs[0]); + const [activeNavTab, setActiveNavTab] = useState('home'); + const insets = useSafeAreaInsets(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + setActiveNavTab('home')} + /> + setActiveNavTab('trade')} + /> + setActiveNavTab('explore')} + /> + setActiveNavTab('account')} + /> + + + + ); +} diff --git a/templates/expo-app/README.md b/templates/expo-app/README.md new file mode 100644 index 000000000..885febf8e --- /dev/null +++ b/templates/expo-app/README.md @@ -0,0 +1,49 @@ +# Coinbase Design System - Expo Template + +A React Native mobile application template integrated with the Coinbase Design System (CDS). + +## Installation + +Use `gitpick` to create a new project from this template: + +```sh +npx -y gitpick coinbase/cds/tree/master/templates/expo-app cds-expo +cd cds-expo +``` + +## Setup + +We suggest [nvm](https://github.com/nvm-sh/nvm/tree/master) to manage Node.js versions. If you have it installed, you can use these commands to set the correct Node.js version. Using corepack ensures you have your package manager setup. + +```sh +nvm install +nvm use +corepack enable +yarn +``` + +## Development + +- `yarn start` - Start the Expo development server +- `yarn ios` - Run on iOS simulator +- `yarn android` - Run on Android emulator +- `yarn web` - Run in web browser + +## Dev Builds + +Some CDS components require native modules that are not available in Expo Go. If you use any of the following components, you will need to create a [development build](https://docs.expo.dev/develop/development-builds/introduction/): + +- `DatePicker` +- `openWebBrowser` +- `AndroidNavigationBar` + +To create a development build: + +```sh +npx expo prebuild +npx expo run:ios # or npx expo run:android +``` + +## Documentation + +Visit [cds.coinbase.com](https://cds.coinbase.com) for the latest CDS documentation and component examples. diff --git a/templates/expo-app/app.json b/templates/expo-app/app.json new file mode 100644 index 000000000..d5a0b64d7 --- /dev/null +++ b/templates/expo-app/app.json @@ -0,0 +1,15 @@ +{ + "expo": { + "name": "cds-expo-app", + "slug": "cds-expo-app", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "light", + "ios": { + "supportsTablet": true + }, + "android": { + "backgroundColor": "#ffffff" + } + } +} diff --git a/templates/expo-app/babel.config.js b/templates/expo-app/babel.config.js new file mode 100644 index 000000000..9d89e1311 --- /dev/null +++ b/templates/expo-app/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/templates/expo-app/components/AssetCarousel.tsx b/templates/expo-app/components/AssetCarousel.tsx new file mode 100644 index 000000000..7bf31422f --- /dev/null +++ b/templates/expo-app/components/AssetCarousel.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { useTheme } from '@coinbase/cds-mobile'; +import { Carousel, CarouselItem } from '@coinbase/cds-mobile/carousel'; +import { MediaCard } from '@coinbase/cds-mobile/cards'; +import { RemoteImage } from '@coinbase/cds-mobile/media'; +import { Text } from '@coinbase/cds-mobile/typography'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; + +const assetList = Object.values(assets); + +export function AssetCarousel() { + const theme = useTheme(); + const horizontalPadding = theme.space[2]; + const horizontalGap = theme.space[2]; + + return ( + + {assetList.map((asset) => ( + + + } + title={asset.symbol} + subtitle={asset.name} + description={ + + Explore + + } + onPress={() => {}} + /> + + ))} + + ); +} diff --git a/templates/expo-app/components/AssetChart.tsx b/templates/expo-app/components/AssetChart.tsx new file mode 100644 index 000000000..3a465f47f --- /dev/null +++ b/templates/expo-app/components/AssetChart.tsx @@ -0,0 +1,116 @@ +import React, { memo, useState, useCallback, useMemo, forwardRef } from 'react'; + +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; +import { useTheme } from '@coinbase/cds-mobile'; +import { VStack } from '@coinbase/cds-mobile/layout'; +import { RemoteImage } from '@coinbase/cds-mobile/media'; +import { Text } from '@coinbase/cds-mobile/typography'; +import { SectionHeader } from '@coinbase/cds-mobile/section-header'; +import { SegmentedTab } from '@coinbase/cds-mobile/tabs/SegmentedTab'; +import { + ChartBridgeProvider, + LineChart, + PeriodSelector, + PeriodSelectorActiveIndicator, +} from '@coinbase/cds-mobile-visualization'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; + +const btcColor = assets.btc.color; + +const tabs = [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, +]; + +const BTCActiveIndicator = memo((props: any) => { + return ; +}); + +const BTCTab = memo( + forwardRef(({ label, ...props }: any, ref: any) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + const theme = useTheme(); + + const wrappedLabel = + typeof label === 'string' ? ( + + {label} + + ) : ( + label + ); + + return ; + }), +); + +const priceFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +function formatPrice(price: number) { + return priceFormatter.format(price); +} + +export const AssetChart = memo(function AssetChart() { + const [timePeriod, setTimePeriod] = useState(tabs[0]); + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const sparklineValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const currentPrice = + sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value; + + const onPeriodChange = useCallback((period: TabValue | null) => { + setTimePeriod(period || tabs[0]); + }, []); + + return ( + + + {formatPrice(currentPrice)}} + end={ + + + + } + title={Bitcoin} + /> + + + + + ); +}); diff --git a/templates/expo-app/components/AssetList.tsx b/templates/expo-app/components/AssetList.tsx new file mode 100644 index 000000000..f9f5a6616 --- /dev/null +++ b/templates/expo-app/components/AssetList.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { VStack } from '@coinbase/cds-mobile/layout'; +import { Text } from '@coinbase/cds-mobile/typography'; +import { ListCell } from '@coinbase/cds-mobile/cells'; +import { Avatar } from '@coinbase/cds-mobile/media'; +import { assets as cdsAssets } from '@coinbase/cds-common/internal/data/assets'; + +const assetData = [ + { + key: 'btc', + name: 'Bitcoin', + symbol: 'BTC', + price: '$67,432.18', + change: '+2.41%', + }, + { + key: 'eth', + name: 'Ethereum', + symbol: 'ETH', + price: '$3,521.90', + change: '+1.83%', + }, + { + key: 'ada', + name: 'Cardano', + symbol: 'ADA', + price: '$0.6231', + change: '-0.82%', + }, +] as const; + +type AssetKey = keyof typeof cdsAssets; + +export function AssetList() { + return ( + + + Your assets + + {assetData.map((asset) => { + const cdsAsset = cdsAssets[asset.key as AssetKey]; + return ( + + {asset.change} + + } + media={ + + } + accessory="arrow" + onPress={() => {}} + /> + ); + })} + + ); +} diff --git a/templates/expo-app/components/CardList.tsx b/templates/expo-app/components/CardList.tsx new file mode 100644 index 000000000..01652763f --- /dev/null +++ b/templates/expo-app/components/CardList.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Dimensions } from 'react-native'; + +import { useTheme } from '@coinbase/cds-mobile'; +import { Carousel, CarouselItem } from '@coinbase/cds-mobile/carousel'; +import { MessagingCard } from '@coinbase/cds-mobile/cards'; +import { Pictogram } from '@coinbase/cds-mobile/illustrations'; +import { Button } from '@coinbase/cds-mobile/buttons'; + +export function CardList() { + const theme = useTheme(); + const windowWidth = Dimensions.get('window').width; + const horizontalPadding = theme.space[2]; + const horizontalGap = theme.space[2]; + const carouselWidth = windowWidth - horizontalPadding * 2; + const itemWidth = (carouselWidth - horizontalGap) / 1.1; + + return ( + + + + } + mediaPlacement="end" + action="Get started" + onActionButtonPress={() => {}} + onDismissButtonPress={() => {}} + /> + + + + } + mediaPlacement="end" + action={ + + } + onDismissButtonPress={() => {}} + /> + + + + + } + mediaPlacement="end" + action="Start learning" + onActionButtonPress={() => {}} + onDismissButtonPress={() => {}} + /> + + + ); +} diff --git a/templates/expo-app/components/Navbar.tsx b/templates/expo-app/components/Navbar.tsx new file mode 100644 index 000000000..c66754bf6 --- /dev/null +++ b/templates/expo-app/components/Navbar.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '@coinbase/cds-mobile'; +import { HStack, Box } from '@coinbase/cds-mobile/layout'; +import { Avatar } from '@coinbase/cds-mobile/media'; +import { TopNavBar, NavBarIconButton, NavigationTitle } from '@coinbase/cds-mobile/navigation'; + +export function Navbar({ toggleColorScheme }: { toggleColorScheme: () => void }) { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const isDark = theme.activeColorScheme === 'dark'; + + return ( + + } + end={ + + + + + } + > + Home + + + ); +} diff --git a/templates/expo-app/components/TabBarButton.tsx b/templates/expo-app/components/TabBarButton.tsx new file mode 100644 index 000000000..71aeaec06 --- /dev/null +++ b/templates/expo-app/components/TabBarButton.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Pressable, type StyleProp, type ViewStyle } from 'react-native'; + +import { useTheme } from '@coinbase/cds-mobile'; +import { VStack } from '@coinbase/cds-mobile/layout'; +import { Icon } from '@coinbase/cds-mobile/icons'; +import { Text } from '@coinbase/cds-mobile/typography'; +import type { IconName } from '@coinbase/cds-common/types'; + +export type TabBarButtonProps = { + icon: IconName; + label: string; + active?: boolean; + onPress?: () => void; + style?: StyleProp; +}; + +export function TabBarButton({ icon, label, active = false, onPress, style }: TabBarButtonProps) { + const theme = useTheme(); + + return ( + + + + + {label} + + + + ); +} diff --git a/templates/expo-app/metro.config.js b/templates/expo-app/metro.config.js new file mode 100644 index 000000000..a49e94d80 --- /dev/null +++ b/templates/expo-app/metro.config.js @@ -0,0 +1,23 @@ +const path = require('path'); +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); + +config.resolver.unstable_enablePackageExports = true; + +// Force @react-spring/native to use its CJS entry point. +// The ESM (.modern.mjs) entry uses a __require("react-native") polyfill +// that Metro cannot resolve when package exports are enabled. +const originalResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName === '@react-spring/native') { + return { + type: 'sourceFile', + filePath: path.resolve(__dirname, 'node_modules/@react-spring/native/dist/cjs/index.js'), + }; + } + const resolve = originalResolveRequest ?? context.resolveRequest; + return resolve(context, moduleName, platform); +}; + +module.exports = config; diff --git a/templates/expo-app/package.json b/templates/expo-app/package.json new file mode 100644 index 000000000..3a4d62414 --- /dev/null +++ b/templates/expo-app/package.json @@ -0,0 +1,47 @@ +{ + "name": "cds-expo-app", + "main": "expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + }, + "dependencies": { + "@expo-google-fonts/inter": "^0.2.3", + "@coinbase/cds-common": "^8", + "@coinbase/cds-icons": "^5", + "@coinbase/cds-illustrations": "^4", + "@coinbase/cds-mobile": "^8", + "@coinbase/cds-mobile-visualization": "beta", + "@dotlottie/react-player": "1.6.1", + "@lottiefiles/dotlottie-react": "0.6.5", + "@lottiefiles/react-lottie-player": "3.5.3", + "@react-navigation/native": "6.1.17", + "@react-navigation/native-stack": "6.9.26", + "@react-navigation/stack": "6.4.1", + "@shopify/react-native-skia": "1.2.3", + "expo": "~51.0.28", + "expo-status-bar": "~1.12.1", + "lottie-react-native": "6.7.0", + "metro": "0.80.12", + "react": "18.2.0", + "react-native": "0.74.5", + "react-native-date-picker": "5.0.13", + "react-native-gesture-handler": "~2.16.1", + "react-native-inappbrowser-reborn": "3.7.0", + "react-native-linear-gradient": "2.8.3", + "react-native-navigation-bar-color": "2.0.2", + "react-native-reanimated": "~3.10.1", + "react-native-safe-area-context": "4.10.5", + "react-native-screens": "3.31.1", + "react-native-svg": "15.2.0" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@types/react": "~18.2.0", + "typescript": "~5.3.0" + }, + "private": true, + "packageManager": "yarn@4.9.2" +} diff --git a/templates/expo-app/tsconfig.json b/templates/expo-app/tsconfig.json new file mode 100644 index 000000000..ed8ca65f9 --- /dev/null +++ b/templates/expo-app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo/tsconfig.base.json", + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "moduleResolution": "bundler" + } +}