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/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. + + + )} + + ); + }, +});