From 45fa08e29928e110eeaac66e136304e131211a8c Mon Sep 17 00:00:00 2001 From: "forge[bot]" <1548036+forge[bot]@users.noreply.ghe.com> Date: Thu, 4 Jun 2026 16:37:15 +0000 Subject: [PATCH 1/5] refactor(mobile): migrate forwardRef to ref-as-prop (CDS-2123) Apply the official React codemod and manual fixups to replace React.forwardRef wrapper pattern with ref-as-prop across 134 files in packages/mobile. Manual fixups cover: generic ForwardRefWithPeriod typings, React.ForwardRefExoticComponent aliases, displayName patterns, and re-exports. Test infrastructure changes are pending a resolution on React 19 SimpleMemoComponent fiber semantics that affect UNSAFE_*ByType queries and the isPressable accessibility helper. Co-authored-by: Erich Kuerschner --- .../mobile/src/accordion/AccordionHeader.tsx | 80 +- .../mobile/src/accordion/AccordionPanel.tsx | 40 +- .../mobile/src/alpha/carousel/Carousel.tsx | 478 ++++--- .../mobile/src/alpha/combobox/Combobox.tsx | 395 +++--- .../mobile/src/alpha/data-card/DataCard.tsx | 56 +- .../src/alpha/select-chip/SelectChip.tsx | 62 +- .../alpha/select-chip/SelectChipControl.tsx | 262 ++-- .../alpha/select/DefaultSelectAllOption.tsx | 88 +- .../src/alpha/select/DefaultSelectControl.tsx | 712 +++++----- .../alpha/select/DefaultSelectDropdown.tsx | 626 +++++---- .../DefaultSelectEmptyDropdownContents.tsx | 12 +- .../src/alpha/select/DefaultSelectOption.tsx | 42 +- packages/mobile/src/alpha/select/Select.tsx | 356 ++--- .../__tests__/DefaultSelectDropdown.test.tsx | 27 +- .../src/alpha/tabbed-chips/TabbedChips.tsx | 148 +- packages/mobile/src/animation/Lottie.tsx | 101 +- packages/mobile/src/banner/Banner.tsx | 385 +++--- packages/mobile/src/buttons/Button.tsx | 318 ++--- .../buttons/DefaultSlideButtonBackground.tsx | 110 +- .../src/buttons/DefaultSlideButtonHandle.tsx | 162 ++- packages/mobile/src/buttons/IconButton.tsx | 11 +- .../mobile/src/buttons/IconCounterButton.tsx | 126 +- packages/mobile/src/buttons/SlideButton.tsx | 11 +- packages/mobile/src/cards/CardGroup.tsx | 56 +- packages/mobile/src/cards/CardRoot.tsx | 13 +- .../src/cards/ContentCard/ContentCard.tsx | 60 +- .../src/cards/ContentCard/ContentCardBody.tsx | 186 ++- .../cards/ContentCard/ContentCardFooter.tsx | 22 +- .../cards/ContentCard/ContentCardHeader.tsx | 162 ++- packages/mobile/src/cards/MediaCard/index.tsx | 60 +- .../mobile/src/cards/MessagingCard/index.tsx | 100 +- packages/mobile/src/carousel/Carousel.tsx | 1202 ++++++++--------- packages/mobile/src/chips/Chip.tsx | 179 +-- packages/mobile/src/chips/InputChip.tsx | 11 +- packages/mobile/src/chips/MediaChip.tsx | 111 +- packages/mobile/src/chips/SelectChip.tsx | 238 ++-- packages/mobile/src/chips/TabbedChips.tsx | 124 +- packages/mobile/src/coachmark/Coachmark.tsx | 11 +- .../mobile/src/collapsible/Collapsible.tsx | 11 +- packages/mobile/src/controls/Checkbox.tsx | 19 +- packages/mobile/src/controls/CheckboxCell.tsx | 16 +- .../mobile/src/controls/CheckboxGroup.tsx | 33 +- packages/mobile/src/controls/Control.tsx | 19 +- packages/mobile/src/controls/ControlGroup.tsx | 18 +- .../mobile/src/controls/InputIconButton.tsx | 56 +- packages/mobile/src/controls/NativeInput.tsx | 167 ++- packages/mobile/src/controls/Radio.tsx | 19 +- packages/mobile/src/controls/RadioCell.tsx | 16 +- packages/mobile/src/controls/RadioGroup.tsx | 39 +- packages/mobile/src/controls/SearchInput.tsx | 11 +- packages/mobile/src/controls/Select.tsx | 365 +++-- packages/mobile/src/controls/Switch.tsx | 14 +- packages/mobile/src/controls/TextInput.tsx | 10 +- packages/mobile/src/dates/Calendar.tsx | 253 ++-- packages/mobile/src/dates/DateInput.tsx | 11 +- packages/mobile/src/dates/DatePicker.tsx | 11 +- .../mobile/src/examples/ExampleScreen.tsx | 82 +- packages/mobile/src/layout/Box.tsx | 420 +++--- packages/mobile/src/layout/Group.tsx | 69 +- packages/mobile/src/layout/HStack.tsx | 19 +- packages/mobile/src/layout/VStack.tsx | 19 +- .../mobile/src/media/Carousel/Carousel.tsx | 239 ++-- packages/mobile/src/motion/ColorSurge.tsx | 87 +- packages/mobile/src/motion/Pulse.tsx | 122 +- packages/mobile/src/motion/Shake.tsx | 90 +- .../MultiContentModule.tsx | 140 +- .../DefaultRollingNumberAffixSection.tsx | 68 +- .../DefaultRollingNumberDigit.tsx | 217 ++- .../DefaultRollingNumberMask.tsx | 13 +- .../DefaultRollingNumberSymbol.tsx | 45 +- .../DefaultRollingNumberValueSection.tsx | 266 ++-- .../numbers/RollingNumber/RollingNumber.tsx | 11 +- packages/mobile/src/numpad/Numpad.tsx | 11 +- packages/mobile/src/overlays/Alert.tsx | 11 +- packages/mobile/src/overlays/Toast.tsx | 11 +- .../mobile/src/overlays/drawer/Drawer.tsx | 10 +- packages/mobile/src/overlays/modal/Modal.tsx | 19 +- packages/mobile/src/overlays/tray/Tray.tsx | 234 ++-- packages/mobile/src/page/PageFooter.tsx | 11 +- packages/mobile/src/page/PageHeader.tsx | 11 +- .../src/section-header/SectionHeader.tsx | 110 +- packages/mobile/src/stepper/Stepper.tsx | 402 +++--- .../mobile/src/sticky-footer/StickyFooter.tsx | 68 +- packages/mobile/src/system/Pressable.tsx | 530 ++++---- packages/mobile/src/tabs/DefaultTab.tsx | 116 +- packages/mobile/src/tabs/SegmentedTab.tsx | 178 +-- packages/mobile/src/tabs/SegmentedTabs.tsx | 53 +- packages/mobile/src/tabs/TabIndicator.tsx | 58 +- packages/mobile/src/tabs/TabNavigation.tsx | 286 ++-- packages/mobile/src/tabs/Tabs.tsx | 218 +-- packages/mobile/src/tag/Tag.tsx | 11 +- .../mobile/src/tour/DefaultTourStepArrow.tsx | 13 +- packages/mobile/src/tour/Tour.tsx | 2 +- packages/mobile/src/typography/Text.tsx | 462 +++---- packages/mobile/src/typography/TextBody.tsx | 12 +- .../mobile/src/typography/TextCaption.tsx | 12 +- .../mobile/src/typography/TextDisplay1.tsx | 15 +- .../mobile/src/typography/TextDisplay2.tsx | 15 +- .../mobile/src/typography/TextDisplay3.tsx | 15 +- .../mobile/src/typography/TextHeadline.tsx | 12 +- .../mobile/src/typography/TextInherited.tsx | 12 +- packages/mobile/src/typography/TextLabel1.tsx | 12 +- packages/mobile/src/typography/TextLabel2.tsx | 12 +- packages/mobile/src/typography/TextLegal.tsx | 12 +- packages/mobile/src/typography/TextTitle1.tsx | 15 +- packages/mobile/src/typography/TextTitle2.tsx | 15 +- packages/mobile/src/typography/TextTitle3.tsx | 12 +- packages/mobile/src/typography/TextTitle4.tsx | 12 +- .../mobile/src/visualizations/ProgressBar.tsx | 11 +- .../src/visualizations/ProgressCircle.tsx | 11 +- .../src/visualizations/ProgressIndicator.tsx | 93 +- .../visualizations/chart/CartesianChart.tsx | 874 ++++++------ .../visualizations/chart/PeriodSelector.tsx | 125 +- .../ChartAccessibility.stories.tsx | 12 +- .../__stories__/PeriodSelector.stories.tsx | 85 +- .../visualizations/chart/area/AreaChart.tsx | 310 +++-- .../src/visualizations/chart/bar/BarChart.tsx | 260 ++-- .../chart/bar/PercentageBarChart.tsx | 128 +- .../visualizations/chart/legend/Legend.tsx | 116 +- .../visualizations/chart/line/LineChart.tsx | 273 ++-- .../line/__stories__/LineChart.stories.tsx | 12 +- .../chart/scrubber/DefaultScrubberBeacon.tsx | 372 +++-- .../chart/scrubber/Scrubber.tsx | 457 +++---- .../chart/scrubber/ScrubberBeaconGroup.tsx | 161 +-- .../visualizations/sparkline/Sparkline.tsx | 284 ++-- .../sparkline/SparklineArea.tsx | 31 +- .../sparkline/SparklineGradient.tsx | 109 +- .../SparklineInteractiveHeader.tsx | 422 +++--- .../SparklineInteractiveHoverDate.tsx | 214 ++- .../SparklineInteractivePanGestureHandler.tsx | 3 +- 130 files changed, 8817 insertions(+), 8692 deletions(-) diff --git a/packages/mobile/src/accordion/AccordionHeader.tsx b/packages/mobile/src/accordion/AccordionHeader.tsx index 62d543a138..1c6e6e824d 100644 --- a/packages/mobile/src/accordion/AccordionHeader.tsx +++ b/packages/mobile/src/accordion/AccordionHeader.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback } from 'react'; +import React, { memo, useCallback } from 'react'; import type { View } from 'react-native'; import { useAccordionContext } from '@coinbase/cds-common/accordion/AccordionProvider'; import { @@ -85,45 +85,45 @@ export type AccordionHeaderProps = AccordionHeaderBaseProps; * Composes an Accordion Media, Title, and Icon. */ export const AccordionHeader = memo( - forwardRef( - ( - { itemKey, title, subtitle, onPress, media, collapsed, testID }: AccordionHeaderProps, - forwardedRef: React.ForwardedRef, - ) => { - const { setActiveKey, activeKey } = useAccordionContext(); - const spacing = useCellSpacing(); - const accessibilityLabel = subtitle ? `${title}, ${subtitle}` : title; + ({ + ref: forwardedRef, + itemKey, + title, + subtitle, + onPress, + media, + collapsed, + testID, + }: AccordionHeaderProps & { + ref?: React.Ref; + }) => { + const { setActiveKey, activeKey } = useAccordionContext(); + const spacing = useCellSpacing(); + const accessibilityLabel = subtitle ? `${title}, ${subtitle}` : title; - const handlePress = useCallback(() => { - onPress?.(itemKey); - setActiveKey(itemKey === activeKey ? null : itemKey); - }, [onPress, itemKey, setActiveKey, activeKey]); + const handlePress = useCallback(() => { + onPress?.(itemKey); + setActiveKey(itemKey === activeKey ? null : itemKey); + }, [onPress, itemKey, setActiveKey, activeKey]); - return ( - - - {!!media && } - - - - - ); - }, - ), + return ( + + + {!!media && } + + + + + ); + }, ); diff --git a/packages/mobile/src/accordion/AccordionPanel.tsx b/packages/mobile/src/accordion/AccordionPanel.tsx index c4818feea6..c96b54e459 100644 --- a/packages/mobile/src/accordion/AccordionPanel.tsx +++ b/packages/mobile/src/accordion/AccordionPanel.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import { accordionVisibleMaxHeight } from '@coinbase/cds-common/animation/accordion'; import { accordionSpacing } from '@coinbase/cds-common/tokens/accordion'; @@ -23,22 +23,24 @@ export type AccordionPanelProps = AccordionPanelBaseProps; * Accepts a unique `itemKey` prop to uniquely identify one panel from another. */ export const AccordionPanel = memo( - forwardRef( - ( - { children, collapsed = true, testID }: AccordionPanelProps, - forwardedRef: React.ForwardedRef, - ) => { - return ( - - {children} - - ); - }, - ), + ({ + ref: forwardedRef, + children, + collapsed = true, + testID, + }: AccordionPanelProps & { + ref?: React.Ref; + }) => { + return ( + + {children} + + ); + }, ); diff --git a/packages/mobile/src/alpha/carousel/Carousel.tsx b/packages/mobile/src/alpha/carousel/Carousel.tsx index 585c1c209f..76a6aeb482 100644 --- a/packages/mobile/src/alpha/carousel/Carousel.tsx +++ b/packages/mobile/src/alpha/carousel/Carousel.tsx @@ -1,12 +1,4 @@ -import React, { - forwardRef, - memo, - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; +import React, { memo, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Animated, StyleSheet } from 'react-native'; import type { ScrollView, ScrollViewProps } from 'react-native'; import { useSafeAreaFrame } from 'react-native-safe-area-context'; @@ -68,258 +60,256 @@ export type CarouselProps = { * @deprecationExpectedRemoval v8 */ export const Carousel = memo( - forwardRef( - ( - { - carouselRef, - items, - gap = 0, - testID = 'Carousel', - onDismissItem, - onDismissLastItem, - showProgress = false, - showDismiss = false, - itemWidth: itemWidthProp, - autoHeight = false, - dismissButtonAccessibilityLabel, - dismissButtonAccessibilityHint, - ...otherProps - }, - forwardedRef, - ) => { - const theme = useTheme(); - const { width: screenWidth } = useSafeAreaFrame(); - const itemWidth = itemWidthProp ?? screenWidth; - const [scrollRef, { scrollTo, scrollToEnd }] = useScrollTo(forwardedRef); - const { onScroll, xOffset, currentIndex } = useScrollOffset(); - const [dismissedItems, setDismissedItems] = useState>(new Set()); - const indicatorsOpacity = useRef(new Animated.Value(1)); - const [mountedItemsInfo, setMountedItemsInfo] = useState({}); - - const resetDismissedItems = useCallback(() => { - setDismissedItems(new Set()); - }, []); + ({ + ref: forwardedRef, + carouselRef, + items, + gap = 0, + testID = 'Carousel', + onDismissItem, + onDismissLastItem, + showProgress = false, + showDismiss = false, + itemWidth: itemWidthProp, + autoHeight = false, + dismissButtonAccessibilityLabel, + dismissButtonAccessibilityHint, + ...otherProps + }: CarouselProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + const { width: screenWidth } = useSafeAreaFrame(); + const itemWidth = itemWidthProp ?? screenWidth; + const [scrollRef, { scrollTo, scrollToEnd }] = useScrollTo(forwardedRef); + const { onScroll, xOffset, currentIndex } = useScrollOffset(); + const [dismissedItems, setDismissedItems] = useState>(new Set()); + const indicatorsOpacity = useRef(new Animated.Value(1)); + const [mountedItemsInfo, setMountedItemsInfo] = useState({}); - const visibleItems = useMemo( - () => - items - .filter((item) => item.key && !dismissedItems.has(item.key)) - .map((item, index) => ({ id: item.key ?? index, index, children: item })) - .reduce( - (prev, { index, id = index, children }) => { - const snapPoint = index * itemWidth; - return [ - ...prev, - { - id, - index, - snapPoint, - progress: xOffset.interpolate({ - inputRange: [snapPoint - itemWidth, snapPoint, snapPoint + itemWidth], - outputRange: [0, 1, 1], - extrapolate: 'clamp', - }), - children, - }, - ]; - }, - [] as { - id: CarouselId; - snapPoint: number; - progress: Animated.AnimatedInterpolation; - index: number; - children: React.ReactElement; - }[], - ), - [items, itemWidth, dismissedItems, xOffset], - ); + const resetDismissedItems = useCallback(() => { + setDismissedItems(new Set()); + }, []); - /** The number of of CarouselItems */ - const childrenLength = visibleItems.length; + const visibleItems = useMemo( + () => + items + .filter((item) => item.key && !dismissedItems.has(item.key)) + .map((item, index) => ({ id: item.key ?? index, index, children: item })) + .reduce( + (prev, { index, id = index, children }) => { + const snapPoint = index * itemWidth; + return [ + ...prev, + { + id, + index, + snapPoint, + progress: xOffset.interpolate({ + inputRange: [snapPoint - itemWidth, snapPoint, snapPoint + itemWidth], + outputRange: [0, 1, 1], + extrapolate: 'clamp', + }), + children, + }, + ]; + }, + [] as { + id: CarouselId; + snapPoint: number; + progress: Animated.AnimatedInterpolation; + index: number; + children: React.ReactElement; + }[], + ), + [items, itemWidth, dismissedItems, xOffset], + ); - const getDismissItemHandler = useCallback( - function getDismissItemHandler(id: CarouselId) { - return function handleDismissItem() { - setDismissedItems((prev) => new Set(prev).add(id)); - onDismissItem?.(id); + /** The number of of CarouselItems */ + const childrenLength = visibleItems.length; - // if you dismiss the last item you have to scroll to the new end position on Android. - // The ScrollView does not automatically do this - if (id === visibleItems[visibleItems.length - 1].id) { - scrollToEnd({ animated: false }); - } - }; - }, - [onDismissItem, scrollToEnd, visibleItems], - ); + const getDismissItemHandler = useCallback( + function getDismissItemHandler(id: CarouselId) { + return function handleDismissItem() { + setDismissedItems((prev) => new Set(prev).add(id)); + onDismissItem?.(id); - const getOnDismissLastItemHandler = useCallback( - function getOnDismissLastItemHandler(id: CarouselId) { - return function handleOnDismissLastItem() { - onDismissLastItem?.({ id, resetDismissedItems }); - }; - }, - [onDismissLastItem, resetDismissedItems], - ); + // if you dismiss the last item you have to scroll to the new end position on Android. + // The ScrollView does not automatically do this + if (id === visibleItems[visibleItems.length - 1].id) { + scrollToEnd({ animated: false }); + } + }; + }, + [onDismissItem, scrollToEnd, visibleItems], + ); - const getOnMountItemHandler = useCallback(function getOnMountItemHandler( - id: CarouselId, - index: number, - ) { - return function handleOnMountItem(animatedStyles: CarouselItemAnimatedStyles) { - setMountedItemsInfo((prev) => ({ - ...prev, - [id]: { animatedStyles, id, index }, - })); + const getOnDismissLastItemHandler = useCallback( + function getOnDismissLastItemHandler(id: CarouselId) { + return function handleOnDismissLastItem() { + onDismissLastItem?.({ id, resetDismissedItems }); }; - }, []); + }, + [onDismissLastItem, resetDismissedItems], + ); - const memoizedStyles = useMemo(() => { - return [styles.carousel, autoHeight && styles.autoHeight]; - }, [autoHeight]); + const getOnMountItemHandler = useCallback(function getOnMountItemHandler( + id: CarouselId, + index: number, + ) { + return function handleOnMountItem(animatedStyles: CarouselItemAnimatedStyles) { + setMountedItemsInfo((prev) => ({ + ...prev, + [id]: { animatedStyles, id, index }, + })); + }; + }, []); - /** Imperatively handling scrolling Carousel to an item. LayoutMap has the index to x coordinate mapping. */ - const scrollToId = useCallback( - (id: CarouselId, params: ScrollToParams | undefined = {}) => { - const snapPoint = visibleItems.find((item) => item.id === id)?.snapPoint; - if (snapPoint) { - scrollTo({ x: snapPoint, ...params }); - } - }, - [visibleItems, scrollTo], - ); + const memoizedStyles = useMemo(() => { + return [styles.carousel, autoHeight && styles.autoHeight]; + }, [autoHeight]); - /** This object contains any internal data/methods of Carousel that we want to expose to consumers. */ - const publicData: CarouselRef = useMemo( - () => ({ - length: childrenLength, - dismissedItems, - resetDismissedItems, - scrollToId, - scrollTo, - scrollToEnd, - currentIndex, - }), - [ - childrenLength, - dismissedItems, - resetDismissedItems, - scrollToId, - scrollTo, - scrollToEnd, - currentIndex, - ], - ); + /** Imperatively handling scrolling Carousel to an item. LayoutMap has the index to x coordinate mapping. */ + const scrollToId = useCallback( + (id: CarouselId, params: ScrollToParams | undefined = {}) => { + const snapPoint = visibleItems.find((item) => item.id === id)?.snapPoint; + if (snapPoint) { + scrollTo({ x: snapPoint, ...params }); + } + }, + [visibleItems, scrollTo], + ); + + /** This object contains any internal data/methods of Carousel that we want to expose to consumers. */ + const publicData: CarouselRef = useMemo( + () => ({ + length: childrenLength, + dismissedItems, + resetDismissedItems, + scrollToId, + scrollTo, + scrollToEnd, + currentIndex, + }), + [ + childrenLength, + dismissedItems, + resetDismissedItems, + scrollToId, + scrollTo, + scrollToEnd, + currentIndex, + ], + ); - /** - * Useful if you need access to carousel length or scrollToId outside of Carousel. The useCarousel hook exposes these values and requires the ref returned to be passed into Carousel's carouselRef prop. - * @example - * ``` - * const carouselRef = useCarousel() - * const handlePress = () => carouselRef.current.scrollToId('item3'); - * - * - * ``` - */ - useImperativeHandle(carouselRef, () => publicData, [publicData]); + /** + * Useful if you need access to carousel length or scrollToId outside of Carousel. The useCarousel hook exposes these values and requires the ref returned to be passed into Carousel's carouselRef prop. + * @example + * ``` + * const carouselRef = useCarousel() + * const handlePress = () => carouselRef.current.scrollToId('item3'); + * + * + * ``` + */ + useImperativeHandle(carouselRef, () => publicData, [publicData]); + + /** Loop over our children and create CarouselItem component. */ + const content = useMemo(() => { + return visibleItems.map(({ index, id, children }) => { + const isLastItem = index === visibleItems.length - 1; + return ( + + {children} + + ); + }); + }, [ + visibleItems, + gap, + showDismiss, + xOffset, + itemWidth, + getDismissItemHandler, + getOnDismissLastItemHandler, + getOnMountItemHandler, + dismissButtonAccessibilityLabel, + dismissButtonAccessibilityHint, + ]); - /** Loop over our children and create CarouselItem component. */ - const content = useMemo(() => { - return visibleItems.map(({ index, id, children }) => { - const isLastItem = index === visibleItems.length - 1; + const progressSpacingEnd = theme.space[0.5]; + const progressIndicators = useMemo(() => { + return visibleItems.map(({ id, progress }) => { + const info = mountedItemsInfo[id]; + if (info) { + const { animatedStyles } = info; return ( - - {children} - + ); - }); - }, [ - visibleItems, - gap, - showDismiss, - xOffset, - itemWidth, - getDismissItemHandler, - getOnDismissLastItemHandler, - getOnMountItemHandler, - dismissButtonAccessibilityLabel, - dismissButtonAccessibilityHint, - ]); + } + return null; + }); + }, [visibleItems, mountedItemsInfo, progressSpacingEnd]); - const progressSpacingEnd = theme.space[0.5]; - const progressIndicators = useMemo(() => { - return visibleItems.map(({ id, progress }) => { - const info = mountedItemsInfo[id]; - if (info) { - const { animatedStyles } = info; - return ( - - ); - } - return null; - }); - }, [visibleItems, mountedItemsInfo, progressSpacingEnd]); + const progressHeight = theme.space[gutter]; - const progressHeight = theme.space[gutter]; - - return ( - - {showProgress && ( - - {progressIndicators} - - )} - + {showProgress && ( + - {content} - - - ); - }, - ), + {progressIndicators} + + )} + + {content} + + + ); + }, ); const styles = StyleSheet.create({ diff --git a/packages/mobile/src/alpha/combobox/Combobox.tsx b/packages/mobile/src/alpha/combobox/Combobox.tsx index bed6a2b307..821b4a693b 100644 --- a/packages/mobile/src/alpha/combobox/Combobox.tsx +++ b/packages/mobile/src/alpha/combobox/Combobox.tsx @@ -1,6 +1,5 @@ import { createContext, - forwardRef, memo, useCallback, useContext, @@ -166,215 +165,215 @@ const ComboboxControlContextAdapter = memo( ) as ComboboxControlContextAdapterType; const ComboboxBase = memo( - forwardRef( - ( - _props: ComboboxProps, - ref: React.Ref, - ) => { - const mergedProps = useComponentConfig('Combobox', _props); - const { - type = 'single' as Type, - value, - onChange, - options, - open: openProp, - setOpen: setOpenProp, - label, - placeholder, - disabled, - variant, - startNode, - endNode, - align, - accessibilityLabel = typeof label === 'string' ? label : 'Combobox control', - defaultOpen, - searchText: searchTextProp, - onSearch: onSearchProp, - defaultSearchText = '', - closeButtonLabel = 'Done', - filterFunction, - SelectControlComponent = DefaultSelectControl, - ComboboxControlComponent = DefaultComboboxControl, - SelectDropdownComponent = DefaultSelectDropdown, - hideSearchInput, - font, - ...props - } = mergedProps; - const [searchTextInternal, setSearchTextInternal] = useState(defaultSearchText); - const searchText = searchTextProp ?? searchTextInternal; - const setSearchText = onSearchProp ?? setSearchTextInternal; - if ((typeof searchTextProp === 'undefined') !== (typeof onSearchProp === 'undefined')) { - throw Error( - 'Combobox component must be fully controlled or uncontrolled: "searchText" and "onSearch" props must be provided together or not at all', - ); - } - - const [openInternal, setOpenInternal] = useState(defaultOpen ?? false); - const open = openProp ?? openInternal; - const setOpen = setOpenProp ?? setOpenInternal; - if ((typeof openProp === 'undefined') !== (typeof setOpenProp === 'undefined')) - throw Error( - 'Combobox component must be fully controlled or uncontrolled: "open" and "setOpen" props must be provided together or not at all', - ); - - const fuse = useMemo( - () => - new Fuse(options, { - keys: ['label', 'description'], - threshold: 0.3, - }), - [options], + ({ + ref, + ..._props + }: ComboboxProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('Combobox', _props); + const { + type = 'single' as Type, + value, + onChange, + options, + open: openProp, + setOpen: setOpenProp, + label, + placeholder, + disabled, + variant, + startNode, + endNode, + align, + accessibilityLabel = typeof label === 'string' ? label : 'Combobox control', + defaultOpen, + searchText: searchTextProp, + onSearch: onSearchProp, + defaultSearchText = '', + closeButtonLabel = 'Done', + filterFunction, + SelectControlComponent = DefaultSelectControl, + ComboboxControlComponent = DefaultComboboxControl, + SelectDropdownComponent = DefaultSelectDropdown, + hideSearchInput, + font, + ...props + } = mergedProps; + const [searchTextInternal, setSearchTextInternal] = useState(defaultSearchText); + const searchText = searchTextProp ?? searchTextInternal; + const setSearchText = onSearchProp ?? setSearchTextInternal; + if ((typeof searchTextProp === 'undefined') !== (typeof onSearchProp === 'undefined')) { + throw Error( + 'Combobox component must be fully controlled or uncontrolled: "searchText" and "onSearch" props must be provided together or not at all', ); + } - const filteredOptions = useMemo(() => { - if (searchText.length === 0) return options; - if (filterFunction) return filterFunction(options, searchText); - return fuse.search(searchText).map((result) => result.item); - }, [filterFunction, fuse, options, searchText]); - - const handleChange = useCallback( - ( - value: Type extends 'multi' - ? SelectOptionValue | SelectOptionValue[] | null - : SelectOptionValue | null, - ) => { - onChange?.(value); - }, - [onChange], + const [openInternal, setOpenInternal] = useState(defaultOpen ?? false); + const open = openProp ?? openInternal; + const setOpen = setOpenProp ?? setOpenInternal; + if ((typeof openProp === 'undefined') !== (typeof setOpenProp === 'undefined')) + throw Error( + 'Combobox component must be fully controlled or uncontrolled: "open" and "setOpen" props must be provided together or not at all', ); - const controlRef = useRef(null); - useImperativeHandle(ref, () => - Object.assign(controlRef.current as ComboboxRef, { - open, - setOpen, + const fuse = useMemo( + () => + new Fuse(options, { + keys: ['label', 'description'], + threshold: 0.3, }), - ); + [options], + ); - const searchInputRef = useRef(null); - const safeBottomPadding = useSafeBottomPadding(); - const handleTrayVisibilityChange = useCallback((visibility: 'visible' | 'hidden') => { - if (visibility === 'visible') { - searchInputRef.current?.focus(); - } - }, []); + const filteredOptions = useMemo(() => { + if (searchText.length === 0) return options; + if (filterFunction) return filterFunction(options, searchText); + return fuse.search(searchText).map((result) => result.item); + }, [filterFunction, fuse, options, searchText]); - const ComboboxControl = useCallback( - (props: SelectControlProps) => { - return ( - - ); - }, - [ComboboxControlComponent, SelectControlComponent, font, searchInputRef], - ); + const handleChange = useCallback( + ( + value: Type extends 'multi' + ? SelectOptionValue | SelectOptionValue[] | null + : SelectOptionValue | null, + ) => { + onChange?.(value); + }, + [onChange], + ); - const ComboboxDropdown = useCallback( - (props: SelectDropdownProps) => ( - (null); + useImperativeHandle(ref, () => + Object.assign(controlRef.current as ComboboxRef, { + open, + setOpen, + }), + ); + + const searchInputRef = useRef(null); + const safeBottomPadding = useSafeBottomPadding(); + const handleTrayVisibilityChange = useCallback((visibility: 'visible' | 'hidden') => { + if (visibility === 'visible') { + searchInputRef.current?.focus(); + } + }, []); + + const ComboboxControl = useCallback( + (props: SelectControlProps) => { + return ( + ( - + ); + }, + [ComboboxControlComponent, SelectControlComponent, font, searchInputRef], + ); + + const ComboboxDropdown = useCallback( + (props: SelectDropdownProps) => ( + ( + + - - - - - - - )} - header={ - - - - } - onVisibilityChange={handleTrayVisibilityChange} - /> - ), - [ - ComboboxControl, - SelectDropdownComponent, - accessibilityLabel, - align, - closeButtonLabel, - endNode, - font, - handleTrayVisibilityChange, - label, - placeholder, - safeBottomPadding, - startNode, - variant, - ], - ); + + + + + )} + header={ + + + + } + onVisibilityChange={handleTrayVisibilityChange} + /> + ), + [ + ComboboxControl, + SelectDropdownComponent, + accessibilityLabel, + align, + closeButtonLabel, + endNode, + font, + handleTrayVisibilityChange, + label, + placeholder, + safeBottomPadding, + startNode, + variant, + ], + ); - return ( - - + + ); + }, ); export const Combobox = ComboboxBase as ComboboxComponent; diff --git a/packages/mobile/src/alpha/data-card/DataCard.tsx b/packages/mobile/src/alpha/data-card/DataCard.tsx index b6b8b8696b..c8b4980314 100644 --- a/packages/mobile/src/alpha/data-card/DataCard.tsx +++ b/packages/mobile/src/alpha/data-card/DataCard.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo } from 'react'; +import { memo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common'; @@ -22,34 +22,32 @@ const dataCardContainerProps = { }; export const DataCard = memo( - forwardRef( - ( - { - title, - subtitle, - titleAccessory, - thumbnail, - children, - layout, - style, - styles: { root: rootStyle, ...layoutStyles } = {}, - ...props - }, - ref, - ) => ( - - - {children} - - - ), + ({ + ref, + title, + subtitle, + titleAccessory, + thumbnail, + children, + layout, + style, + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }: DataCardProps & { + ref?: React.Ref; + }) => ( + + + {children} + + ), ); diff --git a/packages/mobile/src/alpha/select-chip/SelectChip.tsx b/packages/mobile/src/alpha/select-chip/SelectChip.tsx index 9734d8b723..44b6830478 100644 --- a/packages/mobile/src/alpha/select-chip/SelectChip.tsx +++ b/packages/mobile/src/alpha/select-chip/SelectChip.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback } from 'react'; +import React, { memo, useCallback } from 'react'; import type { ChipBaseProps } from '../../chips/ChipProps'; import { useComponentConfig } from '../../hooks/useComponentConfig'; @@ -33,37 +33,37 @@ export type SelectChipProps< * Supports both single and multi selection via Select's `type` prop. */ const SelectChipComponent = memo( - forwardRef( - ( - _props: SelectChipProps, - ref: React.Ref, - ) => { - const mergedProps = useComponentConfig('SelectChip', _props); - const { invertColorScheme, numberOfLines, maxWidth, displayValue, ...props } = mergedProps; - const SelectChipControlComponent = useCallback( - (props: SelectControlProps) => { - return ( - - ); - }, - [displayValue, invertColorScheme, maxWidth, numberOfLines], - ); + ({ + ref, + ..._props + }: SelectChipProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('SelectChip', _props); + const { invertColorScheme, numberOfLines, maxWidth, displayValue, ...props } = mergedProps; + const SelectChipControlComponent = useCallback( + (props: SelectControlProps) => { + return ( + + ); + }, + [displayValue, invertColorScheme, maxWidth, numberOfLines], + ); - return ( - - ref={ref} - SelectControlComponent={SelectChipControlComponent} - {...props} - /> - ); - }, - ), + return ( + + ref={ref} + SelectControlComponent={SelectChipControlComponent} + {...props} + /> + ); + }, ); SelectChipComponent.displayName = 'SelectChip'; diff --git a/packages/mobile/src/alpha/select-chip/SelectChipControl.tsx b/packages/mobile/src/alpha/select-chip/SelectChipControl.tsx index 1ab5d6300c..092f141a87 100644 --- a/packages/mobile/src/alpha/select-chip/SelectChipControl.tsx +++ b/packages/mobile/src/alpha/select-chip/SelectChipControl.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { View } from 'react-native'; import type { ChipBaseProps } from '../../chips/ChipProps'; @@ -14,155 +14,153 @@ import { import type { SelectChipBaseProps } from './SelectChip'; const SelectChipControlComponent = memo( - forwardRef( - ( - { - type, - options, - value, - placeholder, - setOpen, - startNode, - endNode: customEndNode, - open, - accessibilityLabel, - accessibilityHint, - disabled, - maxSelectedOptionsToShow = 2, - hiddenSelectedOptionsLabel = 'more', - label, - compact, - invertColorScheme, - numberOfLines, - maxWidth, - displayValue, - }: SelectControlProps & - SelectChipBaseProps & { displayValue?: React.ReactNode }, - ref: React.Ref, - ) => { - const isMultiSelect = type === 'multi'; - const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); + ({ + ref, + type, + options, + value, + placeholder, + setOpen, + startNode, + endNode: customEndNode, + open, + accessibilityLabel, + accessibilityHint, + disabled, + maxSelectedOptionsToShow = 2, + hiddenSelectedOptionsLabel = 'more', + label, + compact, + invertColorScheme, + numberOfLines, + maxWidth, + displayValue, + }: SelectControlProps & + SelectChipBaseProps & { displayValue?: React.ReactNode } & { + ref?: React.Ref; + }) => { + const isMultiSelect = type === 'multi'; + const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); - // Map of options to their values - // If multiple options share the same value, the first occurrence wins (matches native HTML select behavior) - const optionsMap = useMemo(() => { - const map = new Map>(); - const isDev = process.env.NODE_ENV !== 'production'; + // Map of options to their values + // If multiple options share the same value, the first occurrence wins (matches native HTML select behavior) + const optionsMap = useMemo(() => { + const map = new Map>(); + const isDev = process.env.NODE_ENV !== 'production'; - options.forEach((option, optionIndex) => { - if (isSelectOptionGroup(option)) { - option.options.forEach((groupOption, groupOptionIndex) => { - if (groupOption.value !== null) { - const optionValue = groupOption.value as SelectOptionValue; - // Only set if not already present (first wins) - if (!map.has(optionValue)) { - map.set(optionValue, groupOption); - } else if (isDev) { - console.warn( - `[Select] Duplicate option value detected: "${optionValue}". ` + - `The first occurrence will be used for display. ` + - `Found duplicate in group "${option.label}" at index ${groupOptionIndex}. ` + - `First occurrence was at option index ${optionIndex}.`, - ); - } - } - }); - } else { - const singleOption = option as SelectOption; - if (singleOption.value !== null) { - const optionValue = singleOption.value; + options.forEach((option, optionIndex) => { + if (isSelectOptionGroup(option)) { + option.options.forEach((groupOption, groupOptionIndex) => { + if (groupOption.value !== null) { + const optionValue = groupOption.value as SelectOptionValue; // Only set if not already present (first wins) if (!map.has(optionValue)) { - map.set(optionValue, singleOption); + map.set(optionValue, groupOption); } else if (isDev) { - const existingOption = map.get(optionValue); console.warn( `[Select] Duplicate option value detected: "${optionValue}". ` + `The first occurrence will be used for display. ` + - `Found duplicate at option index ${optionIndex}. ` + - `First occurrence label: "${existingOption?.label ?? existingOption?.value ?? 'unknown'}".`, + `Found duplicate in group "${option.label}" at index ${groupOptionIndex}. ` + + `First occurrence was at option index ${optionIndex}.`, ); } } + }); + } else { + const singleOption = option as SelectOption; + if (singleOption.value !== null) { + const optionValue = singleOption.value; + // Only set if not already present (first wins) + if (!map.has(optionValue)) { + map.set(optionValue, singleOption); + } else if (isDev) { + const existingOption = map.get(optionValue); + console.warn( + `[Select] Duplicate option value detected: "${optionValue}". ` + + `The first occurrence will be used for display. ` + + `Found duplicate at option index ${optionIndex}. ` + + `First occurrence label: "${existingOption?.label ?? existingOption?.value ?? 'unknown'}".`, + ); + } } - }); - return map; - }, [options]); - - const labelContent = useMemo(() => { - if (!hasValue) return label ?? placeholder ?? null; - if (displayValue) return displayValue; - if (isMultiSelect) { - const values = value as SelectOptionValue[]; - const visible = values.slice(0, maxSelectedOptionsToShow); - const labels = visible - .map((v) => { - const opt = optionsMap.get(v); - return opt?.label ?? opt?.description ?? opt?.value ?? ''; - }) - .filter(Boolean); - const hiddenCount = values.length - visible.length; - return hiddenCount > 0 - ? `${labels.join(', ')} +${hiddenCount} ${hiddenSelectedOptionsLabel}` - : labels.join(', '); } + }); + return map; + }, [options]); - const opt = optionsMap.get(value as SelectOptionValue); - return opt?.label ?? opt?.description ?? opt?.value ?? placeholder ?? null; - }, [ - hasValue, - label, - placeholder, - displayValue, - isMultiSelect, - optionsMap, - value, - maxSelectedOptionsToShow, - hiddenSelectedOptionsLabel, - ]); - - const endNode = useMemo(() => { - return ( - customEndNode ?? ( - - ) - ); - }, [customEndNode, open, hasValue]); + const labelContent = useMemo(() => { + if (!hasValue) return label ?? placeholder ?? null; + if (displayValue) return displayValue; + if (isMultiSelect) { + const values = value as SelectOptionValue[]; + const visible = values.slice(0, maxSelectedOptionsToShow); + const labels = visible + .map((v) => { + const opt = optionsMap.get(v); + return opt?.label ?? opt?.description ?? opt?.value ?? ''; + }) + .filter(Boolean); + const hiddenCount = values.length - visible.length; + return hiddenCount > 0 + ? `${labels.join(', ')} +${hiddenCount} ${hiddenSelectedOptionsLabel}` + : labels.join(', '); + } - const color = useMemo(() => { - return hasValue ? 'fgInverse' : 'fg'; - }, [hasValue]); - - const background = useMemo(() => { - return hasValue ? 'bgInverse' : 'bgSecondary'; - }, [hasValue]); + const opt = optionsMap.get(value as SelectOptionValue); + return opt?.label ?? opt?.description ?? opt?.value ?? placeholder ?? null; + }, [ + hasValue, + label, + placeholder, + displayValue, + isMultiSelect, + optionsMap, + value, + maxSelectedOptionsToShow, + hiddenSelectedOptionsLabel, + ]); + const endNode = useMemo(() => { return ( - setOpen((s) => !s)} - start={startNode} - > - {labelContent} - + customEndNode ?? ( + + ) ); - }, - ), + }, [customEndNode, open, hasValue]); + + const color = useMemo(() => { + return hasValue ? 'fgInverse' : 'fg'; + }, [hasValue]); + + const background = useMemo(() => { + return hasValue ? 'bgInverse' : 'bgSecondary'; + }, [hasValue]); + + return ( + setOpen((s) => !s)} + start={startNode} + > + {labelContent} + + ); + }, ); export const SelectChipControl = SelectChipControlComponent as < diff --git a/packages/mobile/src/alpha/select/DefaultSelectAllOption.tsx b/packages/mobile/src/alpha/select/DefaultSelectAllOption.tsx index a2f64e5a99..6b24c5afdb 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectAllOption.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectAllOption.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo } from 'react'; +import { memo } from 'react'; import { type View } from 'react-native'; import { Divider } from '../../layout/Divider'; @@ -14,50 +14,48 @@ type DefaultSelectAllOptionBase = < ) => React.ReactElement; const DefaultSelectAllOptionComponent = memo( - forwardRef( - ( - { - accessory, - blendStyles, - compact, - end, - disabled, - label, - media, - onPress, - selected, - style, - type, - styles, - }: SelectOptionProps, - ref: React.Ref, - ) => { - // Note: DefaultSelectOption doesn't support ref yet because Cell doesn't support ref forwarding - // TODO: Pass ref when Cell component supports ref forwarding - return ( - <> - - - - ); - }, - ), + ({ + ref, + accessory, + blendStyles, + compact, + end, + disabled, + label, + media, + onPress, + selected, + style, + type, + styles, + }: SelectOptionProps & { + ref?: React.Ref; + }) => { + // Note: DefaultSelectOption doesn't support ref yet because Cell doesn't support ref forwarding + // TODO: Pass ref when Cell component supports ref forwarding + return ( + <> + + + + ); + }, ); export const DefaultSelectAllOption = DefaultSelectAllOptionComponent as DefaultSelectAllOptionBase; diff --git a/packages/mobile/src/alpha/select/DefaultSelectControl.tsx b/packages/mobile/src/alpha/select/DefaultSelectControl.tsx index 2e72d35ca6..0e4ac85676 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectControl.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { Pressable, type StyleProp, TouchableOpacity, type ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useInputVariant } from '@coinbase/cds-common/hooks/useInputVariant'; @@ -36,320 +36,302 @@ type DefaultSelectControlComponent = < ) => React.ReactElement; export const DefaultSelectControlComponent = memo( - forwardRef( - ( - { - type, - options, - value, - onChange, - open, - placeholder, - disabled, - setOpen, - variant, - helperText, - label, - labelVariant: labelVariantProp, - contentNode, - startNode, - endNode: customEndNode, - compact, - align = 'start', - font = 'body', - bordered = true, - borderWidth = bordered ? 100 : 0, - focusedBorderWidth = bordered ? undefined : 200, - maxSelectedOptionsToShow = 3, - accessibilityLabel, - hiddenSelectedOptionsLabel = 'more', - removeSelectedOptionAccessibilityLabel = 'Remove', - style, - styles, - onBlur, - onFocus, - ...props - }: SelectControlProps, - ref: React.ForwardedRef>, - ) => { - type ValueType = Type extends 'multi' - ? SelectOptionValue | SelectOptionValue[] | null - : SelectOptionValue | null; + ({ + ref, + type, + options, + value, + onChange, + open, + placeholder, + disabled, + setOpen, + variant, + helperText, + label, + labelVariant: labelVariantProp, + contentNode, + startNode, + endNode: customEndNode, + compact, + align = 'start', + font = 'body', + bordered = true, + borderWidth = bordered ? 100 : 0, + focusedBorderWidth = bordered ? undefined : 200, + maxSelectedOptionsToShow = 3, + accessibilityLabel, + hiddenSelectedOptionsLabel = 'more', + removeSelectedOptionAccessibilityLabel = 'Remove', + style, + styles, + onBlur, + onFocus, + ...props + }: SelectControlProps & { + ref?: React.Ref>; + }) => { + type ValueType = Type extends 'multi' + ? SelectOptionValue | SelectOptionValue[] | null + : SelectOptionValue | null; - const theme = useTheme(); - // When compact, labelVariant is ignored - const labelVariant = compact ? undefined : labelVariantProp; - const isMultiSelect = type === 'multi'; - const shouldShowCompactLabel = compact && label && !isMultiSelect; - const shouldShowInsideLabel = labelVariant === 'inside' && !compact && label; - const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); + const theme = useTheme(); + // When compact, labelVariant is ignored + const labelVariant = compact ? undefined : labelVariantProp; + const isMultiSelect = type === 'multi'; + const shouldShowCompactLabel = compact && label && !isMultiSelect; + const shouldShowInsideLabel = labelVariant === 'inside' && !compact && label; + const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); - // Map of options to their values - // If multiple options share the same value, the first occurrence wins (matches native HTML select behavior) - const optionsMap = useMemo(() => { - const map = new Map>(); - const isDev = process.env.NODE_ENV !== 'production'; + // Map of options to their values + // If multiple options share the same value, the first occurrence wins (matches native HTML select behavior) + const optionsMap = useMemo(() => { + const map = new Map>(); + const isDev = process.env.NODE_ENV !== 'production'; - options.forEach((option, optionIndex) => { - if (isSelectOptionGroup(option)) { - option.options.forEach((groupOption, groupOptionIndex) => { - if (groupOption.value !== null) { - const optionValue = groupOption.value as SelectOptionValue; - // Only set if not already present (first wins) - if (!map.has(optionValue)) { - map.set(optionValue, groupOption); - } else if (isDev) { - console.warn( - `[Select] Duplicate option value detected: "${optionValue}". ` + - `The first occurrence will be used for display. ` + - `Found duplicate in group "${option.label}" at index ${groupOptionIndex}. ` + - `First occurrence was at option index ${optionIndex}.`, - ); - } - } - }); - } else { - const singleOption = option as SelectOption; - if (singleOption.value !== null) { - const optionValue = singleOption.value; + options.forEach((option, optionIndex) => { + if (isSelectOptionGroup(option)) { + option.options.forEach((groupOption, groupOptionIndex) => { + if (groupOption.value !== null) { + const optionValue = groupOption.value as SelectOptionValue; // Only set if not already present (first wins) if (!map.has(optionValue)) { - map.set(optionValue, singleOption); + map.set(optionValue, groupOption); } else if (isDev) { - const existingOption = map.get(optionValue); console.warn( `[Select] Duplicate option value detected: "${optionValue}". ` + `The first occurrence will be used for display. ` + - `Found duplicate at option index ${optionIndex}. ` + - `First occurrence label: "${existingOption?.label ?? existingOption?.value ?? 'unknown'}".`, + `Found duplicate in group "${option.label}" at index ${groupOptionIndex}. ` + + `First occurrence was at option index ${optionIndex}.`, ); } } + }); + } else { + const singleOption = option as SelectOption; + if (singleOption.value !== null) { + const optionValue = singleOption.value; + // Only set if not already present (first wins) + if (!map.has(optionValue)) { + map.set(optionValue, singleOption); + } else if (isDev) { + const existingOption = map.get(optionValue); + console.warn( + `[Select] Duplicate option value detected: "${optionValue}". ` + + `The first occurrence will be used for display. ` + + `Found duplicate at option index ${optionIndex}. ` + + `First occurrence label: "${existingOption?.label ?? existingOption?.value ?? 'unknown'}".`, + ); + } } - }); - return map; - }, [options]); + } + }); + return map; + }, [options]); - const singleValueContent = useMemo(() => { - const option = !isMultiSelect ? optionsMap.get(value as SelectOptionValue) : undefined; - const label = option?.label ?? option?.description ?? option?.value ?? placeholder; - return hasValue ? label : placeholder; - }, [hasValue, isMultiSelect, optionsMap, placeholder, value]); + const singleValueContent = useMemo(() => { + const option = !isMultiSelect ? optionsMap.get(value as SelectOptionValue) : undefined; + const label = option?.label ?? option?.description ?? option?.value ?? placeholder; + return hasValue ? label : placeholder; + }, [hasValue, isMultiSelect, optionsMap, placeholder, value]); - const computedControlAccessibilityLabel = useMemo(() => { - // For multi-select, set the label to the content of each selected value and the hidden selected options label - if (isMultiSelect) { - const selectedValues = (value as SelectOptionValue[]) - .map((v) => { - const option = optionsMap.get(v); - return option?.label ?? option?.description ?? option?.value ?? v; - }) - .slice(0, maxSelectedOptionsToShow) - .join(', '); - return `${accessibilityLabel}, ${(value as SelectOptionValue[]).length > 0 ? selectedValues : (placeholder ?? '')}${(value as SelectOptionValue[]).length > maxSelectedOptionsToShow ? ', ' + hiddenSelectedOptionsLabel : ''}`; - } - // If value is React node, fallback to only using passed in accessibility label - return `${accessibilityLabel ?? ''}${typeof singleValueContent === 'string' ? ', ' + singleValueContent : ''}`; - }, [ - accessibilityLabel, - hiddenSelectedOptionsLabel, - isMultiSelect, - maxSelectedOptionsToShow, - optionsMap, - placeholder, - singleValueContent, - value, - ]); + const computedControlAccessibilityLabel = useMemo(() => { + // For multi-select, set the label to the content of each selected value and the hidden selected options label + if (isMultiSelect) { + const selectedValues = (value as SelectOptionValue[]) + .map((v) => { + const option = optionsMap.get(v); + return option?.label ?? option?.description ?? option?.value ?? v; + }) + .slice(0, maxSelectedOptionsToShow) + .join(', '); + return `${accessibilityLabel}, ${(value as SelectOptionValue[]).length > 0 ? selectedValues : (placeholder ?? '')}${(value as SelectOptionValue[]).length > maxSelectedOptionsToShow ? ', ' + hiddenSelectedOptionsLabel : ''}`; + } + // If value is React node, fallback to only using passed in accessibility label + return `${accessibilityLabel ?? ''}${typeof singleValueContent === 'string' ? ', ' + singleValueContent : ''}`; + }, [ + accessibilityLabel, + hiddenSelectedOptionsLabel, + isMultiSelect, + maxSelectedOptionsToShow, + optionsMap, + placeholder, + singleValueContent, + value, + ]); - // Prop value doesn't have default value because it affects the color of the - // animated caret - const focusedVariant = useInputVariant(!!open, variant ?? 'foregroundMuted'); - const { borderFocusedStyle, borderUnfocusedStyle } = useInputBorderStyle( - !!open, - variant ?? 'foregroundMuted', - focusedVariant, - bordered, - borderWidth, - focusedBorderWidth, - ); + // Prop value doesn't have default value because it affects the color of the + // animated caret + const focusedVariant = useInputVariant(!!open, variant ?? 'foregroundMuted'); + const { borderFocusedStyle, borderUnfocusedStyle } = useInputBorderStyle( + !!open, + variant ?? 'foregroundMuted', + focusedVariant, + bordered, + borderWidth, + focusedBorderWidth, + ); - const helperTextNode = useMemo( - () => - typeof helperText === 'string' ? ( - - {helperText} - - ) : ( - helperText - ), - [helperText, variant, styles?.controlHelperTextNode], - ); + const helperTextNode = useMemo( + () => + typeof helperText === 'string' ? ( + + {helperText} + + ) : ( + helperText + ), + [helperText, variant, styles?.controlHelperTextNode], + ); - const labelNode = useMemo(() => { - if (shouldShowInsideLabel || shouldShowCompactLabel) return null; + const labelNode = useMemo(() => { + if (shouldShowInsideLabel || shouldShowCompactLabel) return null; - if (typeof label === 'string') { - return ( - - {label} - - ); - } + if (typeof label === 'string') { + return ( + + {label} + + ); + } - return label; - }, [shouldShowInsideLabel, shouldShowCompactLabel, label, styles?.controlLabelNode]); + return label; + }, [shouldShowInsideLabel, shouldShowCompactLabel, label, styles?.controlLabelNode]); - const inlineLabelNode = useMemo(() => { - if (!shouldShowInsideLabel && !shouldShowCompactLabel) return null; + const inlineLabelNode = useMemo(() => { + if (!shouldShowInsideLabel && !shouldShowCompactLabel) return null; - if (typeof label === 'string') { - return ( - - {label} - - ); - } + if (typeof label === 'string') { + return ( + + {label} + + ); + } - return label; - }, [shouldShowInsideLabel, shouldShowCompactLabel, label, styles?.controlLabelNode]); + return label; + }, [shouldShowInsideLabel, shouldShowCompactLabel, label, styles?.controlLabelNode]); - const valueAlignment = useMemo( - () => (align === 'end' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start'), - [align], - ); + const valueAlignment = useMemo( + () => (align === 'end' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start'), + [align], + ); - const valueNode = useMemo(() => { - if (hasValue && isMultiSelect) { - const valuesToShow = - value.length <= maxSelectedOptionsToShow - ? (value as SelectOptionValue[]) - : (value as SelectOptionValue[]).slice(0, maxSelectedOptionsToShow); - const optionsToShow = valuesToShow - .map((value) => optionsMap.get(value)) - .filter((option): option is SelectOption => option !== undefined); - return ( - - {optionsToShow.map((option) => { - const accessibilityLabel = - typeof option.label === 'string' - ? option.label - : typeof option.description === 'string' - ? option.description - : (option.value ?? ''); - return ( - { - event?.stopPropagation(); - onChange?.(option.value as ValueType); - }} - > - {option.label ?? option.description ?? option.value ?? ''} - - ); - })} - {value.length - maxSelectedOptionsToShow > 0 && ( - - {`+${value.length - maxSelectedOptionsToShow} ${hiddenSelectedOptionsLabel}`} + const valueNode = useMemo(() => { + if (hasValue && isMultiSelect) { + const valuesToShow = + value.length <= maxSelectedOptionsToShow + ? (value as SelectOptionValue[]) + : (value as SelectOptionValue[]).slice(0, maxSelectedOptionsToShow); + const optionsToShow = valuesToShow + .map((value) => optionsMap.get(value)) + .filter((option): option is SelectOption => option !== undefined); + return ( + + {optionsToShow.map((option) => { + const accessibilityLabel = + typeof option.label === 'string' + ? option.label + : typeof option.description === 'string' + ? option.description + : (option.value ?? ''); + return ( + { + event?.stopPropagation(); + onChange?.(option.value as ValueType); + }} + > + {option.label ?? option.description ?? option.value ?? ''} - )} - - ); - } - - return typeof singleValueContent === 'string' ? ( - - {singleValueContent} - - ) : ( - singleValueContent + ); + })} + {value.length - maxSelectedOptionsToShow > 0 && ( + + {`+${value.length - maxSelectedOptionsToShow} ${hiddenSelectedOptionsLabel}`} + + )} + ); - }, [ - hasValue, - isMultiSelect, - singleValueContent, - font, - align, - value, - maxSelectedOptionsToShow, - valueAlignment, - hiddenSelectedOptionsLabel, - optionsMap, - removeSelectedOptionAccessibilityLabel, - disabled, - onChange, - ]); + } - // onBlur/onFocus on ViewProps allow null returns but TouchableOpacity's onBlur/onFocus props do not. - // This appears like a type inconsistency in react-native's type definitions. - const inputNode = useMemo( - () => ( - setOpen((s) => !s)} - style={[{ flexGrow: 1 }, styles?.controlInputNode]} - {...props} - > + return typeof singleValueContent === 'string' ? ( + + {singleValueContent} + + ) : ( + singleValueContent + ); + }, [ + hasValue, + isMultiSelect, + singleValueContent, + font, + align, + value, + maxSelectedOptionsToShow, + valueAlignment, + hiddenSelectedOptionsLabel, + optionsMap, + removeSelectedOptionAccessibilityLabel, + disabled, + onChange, + ]); + + // onBlur/onFocus on ViewProps allow null returns but TouchableOpacity's onBlur/onFocus props do not. + // This appears like a type inconsistency in react-native's type definitions. + const inputNode = useMemo( + () => ( + setOpen((s) => !s)} + style={[{ flexGrow: 1 }, styles?.controlInputNode]} + {...props} + > + - - {!!startNode && ( - - {startNode} - - )} - {shouldShowCompactLabel ? ( - - {inlineLabelNode} - - ) : null} - {shouldShowInsideLabel ? ( - - {inlineLabelNode} - - {valueNode} - {contentNode} - - - ) : ( + {!!startNode && ( + + {startNode} + + )} + {shouldShowCompactLabel ? ( + + {inlineLabelNode} + + ) : null} + {shouldShowInsideLabel ? ( + + {inlineLabelNode} - )} - - - - ), - [ - ref, - computedControlAccessibilityLabel, - disabled, - onBlur, - onFocus, - styles?.controlInputNode, - styles?.controlStartNode, - styles?.controlValueNode, - props, - startNode, - shouldShowCompactLabel, - shouldShowInsideLabel, - inlineLabelNode, - valueAlignment, - valueNode, - contentNode, - setOpen, - ], - ); - - const endNode = useMemo( - () => ( - setOpen((s) => !s)} - > - - {customEndNode ? ( - customEndNode + ) : ( - + + {valueNode} + {contentNode} + )} - - ), - [styles?.controlEndNode, disabled, customEndNode, open, variant, setOpen], - ); - - const inputStackStyles: StyleProp = useMemo( - () => ({ - paddingTop: compact || labelVariant === 'inside' ? theme.space[1] : theme.space[2], - paddingBottom: compact || labelVariant === 'inside' ? theme.space[1] : theme.space[2], - paddingLeft: theme.space[2], - paddingRight: theme.space[2], - }), - [compact, labelVariant, theme.space], - ); + + + ), + [ + ref, + computedControlAccessibilityLabel, + disabled, + onBlur, + onFocus, + styles?.controlInputNode, + styles?.controlStartNode, + styles?.controlValueNode, + props, + startNode, + shouldShowCompactLabel, + shouldShowInsideLabel, + inlineLabelNode, + valueAlignment, + valueNode, + contentNode, + setOpen, + ], + ); - return ( - ( + - ); - }, - ), + onPress={() => setOpen((s) => !s)} + > + + {customEndNode ? ( + customEndNode + ) : ( + + )} + + + ), + [styles?.controlEndNode, disabled, customEndNode, open, variant, setOpen], + ); + + const inputStackStyles: StyleProp = useMemo( + () => ({ + paddingTop: compact || labelVariant === 'inside' ? theme.space[1] : theme.space[2], + paddingBottom: compact || labelVariant === 'inside' ? theme.space[1] : theme.space[2], + paddingLeft: theme.space[2], + paddingRight: theme.space[2], + }), + [compact, labelVariant, theme.space], + ); + + return ( + + ); + }, ); export const DefaultSelectControl = DefaultSelectControlComponent as DefaultSelectControlComponent; diff --git a/packages/mobile/src/alpha/select/DefaultSelectDropdown.tsx b/packages/mobile/src/alpha/select/DefaultSelectDropdown.tsx index 82bd238569..88d1e9f6b8 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectDropdown.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectDropdown.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { type GestureResponderEvent, ScrollView } from 'react-native'; import { Button } from '../../buttons'; @@ -23,338 +23,336 @@ type DefaultSelectDropdownBase = < ) => React.ReactElement; const DefaultSelectDropdownComponent = memo( - forwardRef( - ( - { - type, - options, - value, - onChange, - onVisibilityChange, - open, - setOpen, - controlRef, - disabled, - style, - styles, - compact, - header, - footer, - label, - end, - selectAllLabel = 'Select all', - emptyOptionsLabel = 'No options available', - clearAllLabel = 'Clear all', - hideSelectAll, - accessory, - media, - SelectOptionComponent = DefaultSelectOption, - SelectAllOptionComponent = DefaultSelectAllOption, - SelectEmptyDropdownContentsComponent = DefaultSelectEmptyDropdownContents, - SelectOptionGroupComponent = DefaultSelectOptionGroup, - accessibilityRoles = defaultAccessibilityRoles, - ...props - }: SelectDropdownProps, - ref: React.Ref, - ) => { - type ValueType = Type extends 'multi' - ? SelectOptionValue | SelectOptionValue[] | null - : SelectOptionValue | null; + ({ + ref, + type, + options, + value, + onChange, + onVisibilityChange, + open, + setOpen, + controlRef, + disabled, + style, + styles, + compact, + header, + footer, + label, + end, + selectAllLabel = 'Select all', + emptyOptionsLabel = 'No options available', + clearAllLabel = 'Clear all', + hideSelectAll, + accessory, + media, + SelectOptionComponent = DefaultSelectOption, + SelectAllOptionComponent = DefaultSelectAllOption, + SelectEmptyDropdownContentsComponent = DefaultSelectEmptyDropdownContents, + SelectOptionGroupComponent = DefaultSelectOptionGroup, + accessibilityRoles = defaultAccessibilityRoles, + ...props + }: SelectDropdownProps & { + ref?: React.Ref; + }) => { + type ValueType = Type extends 'multi' + ? SelectOptionValue | SelectOptionValue[] | null + : SelectOptionValue | null; - const dropdownStyles = useMemo(() => [style, styles?.root], [styles?.root, style]); - const optionStyles = useMemo( - () => ({ - optionCell: styles?.optionCell, - optionContent: styles?.optionContent, - optionLabel: styles?.optionLabel, - optionDescription: styles?.optionDescription, - selectAllDivider: styles?.selectAllDivider, - }), - [ - styles?.optionCell, - styles?.optionContent, - styles?.optionLabel, - styles?.optionDescription, - styles?.selectAllDivider, - ], - ); + const dropdownStyles = useMemo(() => [style, styles?.root], [styles?.root, style]); + const optionStyles = useMemo( + () => ({ + optionCell: styles?.optionCell, + optionContent: styles?.optionContent, + optionLabel: styles?.optionLabel, + optionDescription: styles?.optionDescription, + selectAllDivider: styles?.selectAllDivider, + }), + [ + styles?.optionCell, + styles?.optionContent, + styles?.optionLabel, + styles?.optionDescription, + styles?.selectAllDivider, + ], + ); - const optionGroupStyles = useMemo( - () => ({ - optionGroup: styles?.optionGroup, - option: styles?.option, - optionBlendStyles: styles?.optionBlendStyles, - optionCell: styles?.optionCell, - optionContent: styles?.optionContent, - optionLabel: styles?.optionLabel, - optionDescription: styles?.optionDescription, - selectAllDivider: styles?.selectAllDivider, - }), - [ - styles?.optionGroup, - styles?.option, - styles?.optionBlendStyles, - styles?.optionCell, - styles?.optionContent, - styles?.optionLabel, - styles?.optionDescription, - styles?.selectAllDivider, - ], - ); + const optionGroupStyles = useMemo( + () => ({ + optionGroup: styles?.optionGroup, + option: styles?.option, + optionBlendStyles: styles?.optionBlendStyles, + optionCell: styles?.optionCell, + optionContent: styles?.optionContent, + optionLabel: styles?.optionLabel, + optionDescription: styles?.optionDescription, + selectAllDivider: styles?.selectAllDivider, + }), + [ + styles?.optionGroup, + styles?.option, + styles?.optionBlendStyles, + styles?.optionCell, + styles?.optionContent, + styles?.optionLabel, + styles?.optionDescription, + styles?.selectAllDivider, + ], + ); - const emptyDropdownContentsStyles = useMemo( - () => ({ - emptyContentsContainer: styles?.emptyContentsContainer, - emptyContentsText: styles?.emptyContentsText, - }), - [styles?.emptyContentsContainer, styles?.emptyContentsText], - ); + const emptyDropdownContentsStyles = useMemo( + () => ({ + emptyContentsContainer: styles?.emptyContentsContainer, + emptyContentsText: styles?.emptyContentsText, + }), + [styles?.emptyContentsContainer, styles?.emptyContentsText], + ); - // Flatten options for Select All logic, excluding disabled options and options from disabled groups - const flatOptionsForSelectAll = useMemo(() => { - if (disabled) return []; - const result: Array< - SelectOption & SelectOptionCustomUI - > = []; - options.forEach((option) => { - if (isSelectOptionGroup(option)) { - // It's a group, add its enabled options if the group itself is not disabled - if (!option.disabled) { - option.options.forEach((groupOption) => { - if (!groupOption.disabled) { - result.push(groupOption); - } - }); - } - } else { - // It's a single option, add if not disabled - if (!option.disabled) { - result.push(option); - } + // Flatten options for Select All logic, excluding disabled options and options from disabled groups + const flatOptionsForSelectAll = useMemo(() => { + if (disabled) return []; + const result: Array< + SelectOption & SelectOptionCustomUI + > = []; + options.forEach((option) => { + if (isSelectOptionGroup(option)) { + // It's a group, add its enabled options if the group itself is not disabled + if (!option.disabled) { + option.options.forEach((groupOption) => { + if (!groupOption.disabled) { + result.push(groupOption); + } + }); } - }); - return result; - }, [options, disabled]); - - const isMultiSelect = type === 'multi'; - const isSomeOptionsSelected = isMultiSelect ? (value as string[]).length > 0 : false; - // Only count non-disabled options when determining if all are selected - const enabledOptionsCount = flatOptionsForSelectAll.filter((o) => o.value !== null).length; - const isAllOptionsSelected = isMultiSelect - ? enabledOptionsCount > 0 && (value as string[]).length === enabledOptionsCount - : false; + } else { + // It's a single option, add if not disabled + if (!option.disabled) { + result.push(option); + } + } + }); + return result; + }, [options, disabled]); - const toggleSelectAll = useCallback(() => { - if (isAllOptionsSelected) onChange(null); - else - onChange( - flatOptionsForSelectAll - .map(({ value }) => value) - .filter( - (optionValue) => optionValue !== null && !value?.includes(optionValue), - ) as ValueType, - ); - }, [isAllOptionsSelected, onChange, flatOptionsForSelectAll, value]); + const isMultiSelect = type === 'multi'; + const isSomeOptionsSelected = isMultiSelect ? (value as string[]).length > 0 : false; + // Only count non-disabled options when determining if all are selected + const enabledOptionsCount = flatOptionsForSelectAll.filter((o) => o.value !== null).length; + const isAllOptionsSelected = isMultiSelect + ? enabledOptionsCount > 0 && (value as string[]).length === enabledOptionsCount + : false; - const handleClearAll = useCallback( - (event: GestureResponderEvent) => { - event.stopPropagation(); - onChange(null); - }, - [onChange], - ); + const toggleSelectAll = useCallback(() => { + if (isAllOptionsSelected) onChange(null); + else + onChange( + flatOptionsForSelectAll + .map(({ value }) => value) + .filter( + (optionValue) => optionValue !== null && !value?.includes(optionValue), + ) as ValueType, + ); + }, [isAllOptionsSelected, onChange, flatOptionsForSelectAll, value]); - const handleOptionPress = useCallback( - (newValue: SelectOptionValue | null) => { - onChange(newValue as ValueType); - if (!isMultiSelect) setOpen(false); - }, - [onChange, isMultiSelect, setOpen], - ); + const handleClearAll = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + onChange(null); + }, + [onChange], + ); - const indeterminate = !isAllOptionsSelected && isSomeOptionsSelected ? true : false; + const handleOptionPress = useCallback( + (newValue: SelectOptionValue | null) => { + onChange(newValue as ValueType); + if (!isMultiSelect) setOpen(false); + }, + [onChange, isMultiSelect, setOpen], + ); - const SelectAllOption = useMemo( - () => ( - - {clearAllLabel} - - ) - } - indeterminate={indeterminate} - label={`${selectAllLabel} (${flatOptionsForSelectAll.filter((o) => o.value !== null).length})`} - media={ - media ?? ( - - ) - } - onPress={toggleSelectAll} - selected={isAllOptionsSelected} - style={styles?.option} - styles={optionStyles} - type={type} - value={'select-all' as SelectOptionValue} - /> - ), - [ - SelectAllOptionComponent, - accessibilityRoles?.option, - accessory, - styles?.optionBlendStyles, - styles?.option, - compact, - disabled, - end, - handleClearAll, - clearAllLabel, - indeterminate, - selectAllLabel, - flatOptionsForSelectAll, - media, - isAllOptionsSelected, - toggleSelectAll, - optionStyles, - type, - ], - ); + const indeterminate = !isAllOptionsSelected && isSomeOptionsSelected ? true : false; - if (!open) return null; + const SelectAllOption = useMemo( + () => ( + + {clearAllLabel} + + ) + } + indeterminate={indeterminate} + label={`${selectAllLabel} (${flatOptionsForSelectAll.filter((o) => o.value !== null).length})`} + media={ + media ?? ( + + ) + } + onPress={toggleSelectAll} + selected={isAllOptionsSelected} + style={styles?.option} + styles={optionStyles} + type={type} + value={'select-all' as SelectOptionValue} + /> + ), + [ + SelectAllOptionComponent, + accessibilityRoles?.option, + accessory, + styles?.optionBlendStyles, + styles?.option, + compact, + disabled, + end, + handleClearAll, + clearAllLabel, + indeterminate, + selectAllLabel, + flatOptionsForSelectAll, + media, + isAllOptionsSelected, + toggleSelectAll, + optionStyles, + type, + ], + ); - return ( - setOpen(false)} - onDismiss={() => setOpen(false)} - onVisibilityChange={onVisibilityChange} - style={dropdownStyles} - title={label} - verticalDrawerPercentageOfView={0.9} - > - - - {!hideSelectAll && isMultiSelect && options.length > 0 && SelectAllOption} - {options.length > 0 ? ( - options.map((optionOrGroup) => { - // Check if it's a group (has 'options' property and 'label') - if (isSelectOptionGroup(optionOrGroup)) { - const group = optionOrGroup; - return ( - - ); - } + if (!open) return null; - const option = optionOrGroup; - const { - Component: optionComponent, - media: optionMedia, - accessory: optionAccessory, - end: optionEnd, - disabled: optionDisabled, - ...optionProps - } = option; - const RenderedComponent = optionComponent ?? SelectOptionComponent; - const selected = - optionProps.value !== null && isMultiSelect - ? (value as string[]).includes(optionProps.value) - : value === optionProps.value; - /** onPress handlers are passed so that when the media is pressed, - * the onChange handler is called. Since the - * has an accessibilityRole, the inner media won't be detected by a screen reader - * so this behavior matches web - * */ - const defaultMedia = isMultiSelect ? ( - handleOptionPress(optionProps.value)} - tabIndex={-1} - value={optionProps.value?.toString()} - /> - ) : ( - handleOptionPress(optionProps.value)} - tabIndex={-1} - value={optionProps.value?.toString()} - /> - ); + return ( + setOpen(false)} + onDismiss={() => setOpen(false)} + onVisibilityChange={onVisibilityChange} + style={dropdownStyles} + title={label} + verticalDrawerPercentageOfView={0.9} + > + + + {!hideSelectAll && isMultiSelect && options.length > 0 && SelectAllOption} + {options.length > 0 ? ( + options.map((optionOrGroup) => { + // Check if it's a group (has 'options' property and 'label') + if (isSelectOptionGroup(optionOrGroup)) { + const group = optionOrGroup; return ( - ); - }) - ) : ( - - )} - - - - ); - }, - ), + } + + const option = optionOrGroup; + const { + Component: optionComponent, + media: optionMedia, + accessory: optionAccessory, + end: optionEnd, + disabled: optionDisabled, + ...optionProps + } = option; + const RenderedComponent = optionComponent ?? SelectOptionComponent; + const selected = + optionProps.value !== null && isMultiSelect + ? (value as string[]).includes(optionProps.value) + : value === optionProps.value; + /** onPress handlers are passed so that when the media is pressed, + * the onChange handler is called. Since the + * has an accessibilityRole, the inner media won't be detected by a screen reader + * so this behavior matches web + * */ + const defaultMedia = isMultiSelect ? ( + handleOptionPress(optionProps.value)} + tabIndex={-1} + value={optionProps.value?.toString()} + /> + ) : ( + handleOptionPress(optionProps.value)} + tabIndex={-1} + value={optionProps.value?.toString()} + /> + ); + return ( + + ); + }) + ) : ( + + )} + + + + ); + }, ); export const DefaultSelectDropdown = DefaultSelectDropdownComponent as DefaultSelectDropdownBase; diff --git a/packages/mobile/src/alpha/select/DefaultSelectEmptyDropdownContents.tsx b/packages/mobile/src/alpha/select/DefaultSelectEmptyDropdownContents.tsx index 2901449ef6..776426c52e 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectEmptyDropdownContents.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectEmptyDropdownContents.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo } from 'react'; +import { memo } from 'react'; import { type View } from 'react-native'; import { Box } from '../../layout/Box'; @@ -10,7 +10,13 @@ import type { } from './Select'; export const DefaultSelectEmptyDropdownContents: SelectEmptyDropdownContentComponent = memo( - forwardRef(({ label, styles }, ref: React.Ref) => { + ({ + ref, + label, + styles, + }: SelectEmptyDropdownContentProps & { + ref?: React.Ref; + }) => { return ( @@ -18,5 +24,5 @@ export const DefaultSelectEmptyDropdownContents: SelectEmptyDropdownContentCompo ); - }), + }, ); diff --git a/packages/mobile/src/alpha/select/DefaultSelectOption.tsx b/packages/mobile/src/alpha/select/DefaultSelectOption.tsx index 8d46ab2f0a..9b9397a144 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectOption.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectOption.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { type View } from 'react-native'; import { selectCellMobileSpacingConfig } from '@coinbase/cds-common/tokens/select'; @@ -11,26 +11,24 @@ import type { SelectOptionProps, SelectType } from './Select'; const DefaultSelectOptionComponent = < Type extends SelectType, SelectOptionValue extends string = string, ->( - { - value, - label, - onPress, - disabled, - selected, - indeterminate, - compact, - description, - multiline, - style, - styles, - type, - accessibilityRole, - background = 'transparent', - ...props - }: SelectOptionProps, - ref: React.Ref, -) => { +>({ + ref, + value, + label, + onPress, + disabled, + selected, + indeterminate, + compact, + description, + multiline, + style, + styles, + type, + accessibilityRole, + background = 'transparent', + ...props +}: SelectOptionProps & { ref?: React.Ref }) => { const labelNode = useMemo( () => typeof label === 'string' ? ( @@ -97,7 +95,7 @@ const DefaultSelectOptionComponent = < ); }; -export const DefaultSelectOption = memo(forwardRef(DefaultSelectOptionComponent)) as < +export const DefaultSelectOption = memo(DefaultSelectOptionComponent) as < Type extends SelectType = 'single', SelectOptionValue extends string = string, >( diff --git a/packages/mobile/src/alpha/select/Select.tsx b/packages/mobile/src/alpha/select/Select.tsx index 5f69ef30fa..89487a3992 100644 --- a/packages/mobile/src/alpha/select/Select.tsx +++ b/packages/mobile/src/alpha/select/Select.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import React, { memo, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import { useComponentConfig } from '../../hooks/useComponentConfig'; @@ -48,189 +48,189 @@ export type { export { isSelectOptionGroup }; const SelectBase = memo( - forwardRef( - ( - _props: SelectProps, - ref: React.Ref, - ) => { - const mergedProps = useComponentConfig('Select', _props); - const { - value, - type = 'single' as Type, - options, - onChange, - open: openProp, - setOpen: setOpenProp, - disabled, - disableClickOutsideClose, - placeholder, - helperText, - compact, - label, - labelVariant, - accessibilityLabel = typeof label === 'string' ? label : 'Select control', - accessibilityHint, - accessibilityRoles = defaultAccessibilityRoles, - selectAllLabel, - emptyOptionsLabel, - clearAllLabel, - hideSelectAll, - defaultOpen, - startNode, - endNode, - variant, - maxSelectedOptionsToShow, - hiddenSelectedOptionsLabel, - removeSelectedOptionAccessibilityLabel, - accessory, - media, - end, - align, - font, - bordered = true, - SelectOptionComponent = DefaultSelectOption, - SelectAllOptionComponent = DefaultSelectAllOption, - SelectDropdownComponent = DefaultSelectDropdown, - SelectControlComponent = DefaultSelectControl, - SelectEmptyDropdownContentsComponent = DefaultSelectEmptyDropdownContents, - SelectOptionGroupComponent = DefaultSelectOptionGroup, - style, - styles, - testID, - ...props - } = mergedProps; - const [openInternal, setOpenInternal] = useState(defaultOpen ?? false); - const open = openProp ?? openInternal; - const setOpen = setOpenProp ?? setOpenInternal; + ({ + ref, + ..._props + }: SelectProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('Select', _props); + const { + value, + type = 'single' as Type, + options, + onChange, + open: openProp, + setOpen: setOpenProp, + disabled, + disableClickOutsideClose, + placeholder, + helperText, + compact, + label, + labelVariant, + accessibilityLabel = typeof label === 'string' ? label : 'Select control', + accessibilityHint, + accessibilityRoles = defaultAccessibilityRoles, + selectAllLabel, + emptyOptionsLabel, + clearAllLabel, + hideSelectAll, + defaultOpen, + startNode, + endNode, + variant, + maxSelectedOptionsToShow, + hiddenSelectedOptionsLabel, + removeSelectedOptionAccessibilityLabel, + accessory, + media, + end, + align, + font, + bordered = true, + SelectOptionComponent = DefaultSelectOption, + SelectAllOptionComponent = DefaultSelectAllOption, + SelectDropdownComponent = DefaultSelectDropdown, + SelectControlComponent = DefaultSelectControl, + SelectEmptyDropdownContentsComponent = DefaultSelectEmptyDropdownContents, + SelectOptionGroupComponent = DefaultSelectOptionGroup, + style, + styles, + testID, + ...props + } = mergedProps; + const [openInternal, setOpenInternal] = useState(defaultOpen ?? false); + const open = openProp ?? openInternal; + const setOpen = setOpenProp ?? setOpenInternal; - if ( - (typeof openProp === 'undefined' && typeof setOpenProp !== 'undefined') || - (typeof openProp !== 'undefined' && typeof setOpenProp === 'undefined') - ) - throw Error( - 'Select component must be fully controlled or uncontrolled: "open" and "setOpen" props must be provided together or not at all', - ); + if ( + (typeof openProp === 'undefined' && typeof setOpenProp !== 'undefined') || + (typeof openProp !== 'undefined' && typeof setOpenProp === 'undefined') + ) + throw Error( + 'Select component must be fully controlled or uncontrolled: "open" and "setOpen" props must be provided together or not at all', + ); - const rootStyles = useMemo(() => { - return [style, styles?.root]; - }, [style, styles?.root]); + const rootStyles = useMemo(() => { + return [style, styles?.root]; + }, [style, styles?.root]); - const controlStyles = useMemo( - () => ({ - controlStartNode: styles?.controlStartNode, - controlInputNode: styles?.controlInputNode, - controlValueNode: styles?.controlValueNode, - controlLabelNode: styles?.controlLabelNode, - controlHelperTextNode: styles?.controlHelperTextNode, - controlEndNode: styles?.controlEndNode, - }), - [ - styles?.controlStartNode, - styles?.controlInputNode, - styles?.controlValueNode, - styles?.controlLabelNode, - styles?.controlHelperTextNode, - styles?.controlEndNode, - ], - ); + const controlStyles = useMemo( + () => ({ + controlStartNode: styles?.controlStartNode, + controlInputNode: styles?.controlInputNode, + controlValueNode: styles?.controlValueNode, + controlLabelNode: styles?.controlLabelNode, + controlHelperTextNode: styles?.controlHelperTextNode, + controlEndNode: styles?.controlEndNode, + }), + [ + styles?.controlStartNode, + styles?.controlInputNode, + styles?.controlValueNode, + styles?.controlLabelNode, + styles?.controlHelperTextNode, + styles?.controlEndNode, + ], + ); - const dropdownStyles = useMemo( - () => ({ - root: styles?.dropdown, - option: styles?.option, - optionBlendStyles: styles?.optionBlendStyles, - optionCell: styles?.optionCell, - optionContent: styles?.optionContent, - optionLabel: styles?.optionLabel, - optionDescription: styles?.optionDescription, - selectAllDivider: styles?.selectAllDivider, - emptyContentsContainer: styles?.emptyContentsContainer, - emptyContentsText: styles?.emptyContentsText, - optionGroup: styles?.optionGroup, - }), - [ - styles?.dropdown, - styles?.option, - styles?.optionBlendStyles, - styles?.optionCell, - styles?.optionContent, - styles?.optionLabel, - styles?.optionDescription, - styles?.selectAllDivider, - styles?.emptyContentsContainer, - styles?.emptyContentsText, - styles?.optionGroup, - ], - ); + const dropdownStyles = useMemo( + () => ({ + root: styles?.dropdown, + option: styles?.option, + optionBlendStyles: styles?.optionBlendStyles, + optionCell: styles?.optionCell, + optionContent: styles?.optionContent, + optionLabel: styles?.optionLabel, + optionDescription: styles?.optionDescription, + selectAllDivider: styles?.selectAllDivider, + emptyContentsContainer: styles?.emptyContentsContainer, + emptyContentsText: styles?.emptyContentsText, + optionGroup: styles?.optionGroup, + }), + [ + styles?.dropdown, + styles?.option, + styles?.optionBlendStyles, + styles?.optionCell, + styles?.optionContent, + styles?.optionLabel, + styles?.optionDescription, + styles?.selectAllDivider, + styles?.emptyContentsContainer, + styles?.emptyContentsText, + styles?.optionGroup, + ], + ); - const containerRef = useRef(null); - useImperativeHandle(ref, () => - Object.assign(containerRef.current as View, { - open, - setOpen, - refs: { reference: containerRef, floating: null }, - }), - ); + const containerRef = useRef(null); + useImperativeHandle(ref, () => + Object.assign(containerRef.current as View, { + open, + setOpen, + refs: { reference: containerRef, floating: null }, + }), + ); - return ( - - - - - ); - }, - ), + return ( + + + + + ); + }, ); export const Select = SelectBase as SelectComponent; diff --git a/packages/mobile/src/alpha/select/__tests__/DefaultSelectDropdown.test.tsx b/packages/mobile/src/alpha/select/__tests__/DefaultSelectDropdown.test.tsx index 80db1d747d..e709a5d9da 100644 --- a/packages/mobile/src/alpha/select/__tests__/DefaultSelectDropdown.test.tsx +++ b/packages/mobile/src/alpha/select/__tests__/DefaultSelectDropdown.test.tsx @@ -38,16 +38,23 @@ jest.mock('../../../overlays/tray/Tray', () => { const React = require('react'); const { View } = require('react-native'); return { - Tray: React.forwardRef( - ({ children, title, onDismiss, onCloseComplete, ...props }: any, ref: any) => { - return ( - - {title && {title}} - {children} - - ); - }, - ), + Tray: ({ + ref, + children, + title, + onDismiss, + onCloseComplete, + ...props + }: any & { + ref?: React.Ref; + }) => { + return ( + + {title && {title}} + {children} + + ); + }, }; }); diff --git a/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx b/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx index e87d31e63c..f028c4f4ac 100644 --- a/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx +++ b/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { ScrollView, type StyleProp, type View, type ViewStyle } from 'react-native'; import type { SharedAccessibilityProps, SharedProps, ThemeVars } from '@coinbase/cds-common'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; @@ -91,82 +91,82 @@ type TabbedChipsFC = ( props: TabbedChipsProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; -const TabbedChipsComponent = memo( - forwardRef(function TabbedChips( - _props: TabbedChipsProps, - ref: React.ForwardedRef, - ) { - const mergedProps = useComponentConfig('TabbedChips', _props); - const { - tabs, - activeTab = tabs[0], - testID = 'tabbed-chips', - TabComponent = DefaultTabComponent, - onChange, - width, - gap = 1, - compact, - styles, - autoScrollOffset = 30, - ...accessibilityProps - } = mergedProps; - const [scrollTarget, setScrollTarget] = useState(null); - const { - scrollRef, - isScrollContentOverflowing, - isScrollContentOffscreenLeft, - isScrollContentOffscreenRight, - handleScroll, - handleScrollContainerLayout, - handleScrollContentSizeChange, - } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); +const TabbedChipsComponent = memo(function TabbedChips({ + ref, + ..._props +}: TabbedChipsProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('TabbedChips', _props); + const { + tabs, + activeTab = tabs[0], + testID = 'tabbed-chips', + TabComponent = DefaultTabComponent, + onChange, + width, + gap = 1, + compact, + styles, + autoScrollOffset = 30, + ...accessibilityProps + } = mergedProps; + const [scrollTarget, setScrollTarget] = useState(null); + const { + scrollRef, + isScrollContentOverflowing, + isScrollContentOffscreenLeft, + isScrollContentOffscreenRight, + handleScroll, + handleScrollContainerLayout, + handleScrollContentSizeChange, + } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); - const TabComponentWithCompact = useCallback( - (props: TabValue) => { - return ; - }, - [TabComponent, compact], - ); + const TabComponentWithCompact = useCallback( + (props: TabValue) => { + return ; + }, + [TabComponent, compact], + ); - return ( - + - - - - {isScrollContentOverflowing && isScrollContentOffscreenLeft && ( - - )} - {isScrollContentOverflowing && isScrollContentOffscreenRight && ( - - )} - - ); - }), -); + + + {isScrollContentOverflowing && isScrollContentOffscreenLeft && ( + + )} + {isScrollContentOverflowing && isScrollContentOffscreenRight && ( + + )} + + ); +}); TabbedChipsComponent.displayName = 'TabbedChips'; diff --git a/packages/mobile/src/animation/Lottie.tsx b/packages/mobile/src/animation/Lottie.tsx index 1495e64f8d..b6c0b2fbc5 100644 --- a/packages/mobile/src/animation/Lottie.tsx +++ b/packages/mobile/src/animation/Lottie.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated } from 'react-native'; import type { DimensionValue } from 'react-native'; import LottieView from 'lottie-react-native'; @@ -13,60 +13,59 @@ export type LottieMobileRef = React.ForwardedRef; const AnimatedLottieView = Animated.createAnimatedComponent(LottieView); const LottieContent = memo( - forwardRef( - ( - { - autoplay = false, - colorFilters, - loop = false, - progress, - resizeMode = 'contain', - source, - onAnimationFinish, - ...boxProps - }: LottieProps, - forwardedRef: LottieMobileRef, - ) => { - const aspectRatio = source.w / source.h; - const lottieStyles = useMemo( - () => ({ - width: '100%' as DimensionValue, - height: '100%' as DimensionValue, - aspectRatio, - }), - [aspectRatio], - ); + ({ + ref: forwardedRef, + autoplay = false, + colorFilters, + loop = false, + progress, + resizeMode = 'contain', + source, + onAnimationFinish, + ...boxProps + }: LottieProps & { + ref?: React.Ref; + }) => { + const aspectRatio = source.w / source.h; + const lottieStyles = useMemo( + () => ({ + width: '100%' as DimensionValue, + height: '100%' as DimensionValue, + aspectRatio, + }), + [aspectRatio], + ); - return ( - - - - ); - }, - ), + return ( + + + + ); + }, ); export const Lottie = memo( - forwardRef( - ( - { colorFilters: colorFiltersProp, ...props }: LottieProps, - forwardedRef: React.ForwardedRef, - ) => { - const colorFilters = useLottieColorFilters(props.source, colorFiltersProp); - return ; - }, - ), + ({ + ref: forwardedRef, + colorFilters: colorFiltersProp, + ...props + }: LottieProps & { + ref?: React.Ref; + }) => { + const colorFilters = useLottieColorFilters(props.source, colorFiltersProp); + return ; + }, ); LottieContent.displayName = 'LottieContent'; diff --git a/packages/mobile/src/banner/Banner.tsx b/packages/mobile/src/banner/Banner.tsx index 003cd56cd5..726c729a3d 100644 --- a/packages/mobile/src/banner/Banner.tsx +++ b/packages/mobile/src/banner/Banner.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, isValidElement, memo, useCallback, useMemo, useState } from 'react'; +import React, { isValidElement, memo, useCallback, useMemo, useState } from 'react'; import type { StyleProp, TextStyle, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { variants } from '@coinbase/cds-common/tokens/banner'; @@ -113,211 +113,214 @@ export type BannerProps = BannerBaseProps & */ export type MobileBannerProps = BannerProps; -export const Banner = memo( - forwardRef(function Banner(_props: BannerProps, forwardedRef: React.ForwardedRef) { - const mergedProps = useComponentConfig('Banner', _props); - const { - variant, - startIcon, - startIconActive, - onClose, - primaryAction, - secondaryAction, - title, - children, - showDismiss = false, - testID, - numberOfLines = 3, - style, - label, - styleVariant = 'contextual', - startIconAccessibilityLabel, - closeAccessibilityLabel = 'close', - borderRadius = styleVariant === 'contextual' ? 400 : undefined, - margin, - marginX, - marginY, - marginTop, - marginEnd, - marginBottom, - marginStart, - styles, - ...props - } = mergedProps; - const [isCollapsed, setIsCollapsed] = useState(false); - const theme = useTheme(); +export const Banner = memo(function Banner({ + ref: forwardedRef, + ..._props +}: BannerProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('Banner', _props); + const { + variant, + startIcon, + startIconActive, + onClose, + primaryAction, + secondaryAction, + title, + children, + showDismiss = false, + testID, + numberOfLines = 3, + style, + label, + styleVariant = 'contextual', + startIconAccessibilityLabel, + closeAccessibilityLabel = 'close', + borderRadius = styleVariant === 'contextual' ? 400 : undefined, + margin, + marginX, + marginY, + marginTop, + marginEnd, + marginBottom, + marginStart, + styles, + ...props + } = mergedProps; + const [isCollapsed, setIsCollapsed] = useState(false); + const theme = useTheme(); - // Events - const handleOnDismiss = useCallback(() => { - setIsCollapsed(true); - onClose?.(); - }, [onClose]); + // Events + const handleOnDismiss = useCallback(() => { + setIsCollapsed(true); + onClose?.(); + }, [onClose]); - // Setup color configs - const { - iconColor, - textColor, - background, - primaryActionColor, - secondaryActionColor, - iconButtonColor, - borderColor, - } = variants[variant]; + // Setup color configs + const { + iconColor, + textColor, + background, + primaryActionColor, + secondaryActionColor, + iconButtonColor, + borderColor, + } = variants[variant]; - const clonedPrimaryAction = useMemo(() => { - if (!isValidElement(primaryAction)) return null; + const clonedPrimaryAction = useMemo(() => { + if (!isValidElement(primaryAction)) return null; - if (primaryAction.type === Link) { - return React.cloneElement(primaryAction, { - font: 'label1', - color: primaryActionColor, - testID: `${testID}-action--primary`, - ...(primaryAction.props as LinkProps), - }); - } else { - return React.cloneElement(primaryAction, { - testID: `${testID}-action--primary`, - ...(primaryAction.props as any), // we don't know the type of element this ReactNode is - }); - } - }, [primaryAction, primaryActionColor, testID]); + if (primaryAction.type === Link) { + return React.cloneElement(primaryAction, { + font: 'label1', + color: primaryActionColor, + testID: `${testID}-action--primary`, + ...(primaryAction.props as LinkProps), + }); + } else { + return React.cloneElement(primaryAction, { + testID: `${testID}-action--primary`, + ...(primaryAction.props as any), // we don't know the type of element this ReactNode is + }); + } + }, [primaryAction, primaryActionColor, testID]); - const clonedSecondaryAction = useMemo(() => { - if (!isValidElement(secondaryAction)) return null; + const clonedSecondaryAction = useMemo(() => { + if (!isValidElement(secondaryAction)) return null; - if (secondaryAction.type === Link) { - return React.cloneElement(secondaryAction, { - font: 'label1', - color: secondaryActionColor, - testID: `${testID}-action--secondary`, - ...(secondaryAction.props as LinkProps), - }); - } else { - return React.cloneElement(secondaryAction, { - testID: `${testID}-action--secondary`, - ...(secondaryAction.props as any), // we don't know the type of element this ReactNode is - }); - } - }, [secondaryAction, secondaryActionColor, testID]); + if (secondaryAction.type === Link) { + return React.cloneElement(secondaryAction, { + font: 'label1', + color: secondaryActionColor, + testID: `${testID}-action--secondary`, + ...(secondaryAction.props as LinkProps), + }); + } else { + return React.cloneElement(secondaryAction, { + testID: `${testID}-action--secondary`, + ...(secondaryAction.props as any), // we don't know the type of element this ReactNode is + }); + } + }, [secondaryAction, secondaryActionColor, testID]); - const marginStyles = useMemo( - () => ({ - margin, - marginX, - marginY, - marginTop, - marginEnd, - marginBottom, - marginStart, - }), - [margin, marginX, marginY, marginTop, marginEnd, marginBottom, marginStart], - ); + const marginStyles = useMemo( + () => ({ + margin, + marginX, + marginY, + marginTop, + marginEnd, + marginBottom, + marginStart, + }), + [margin, marginX, marginY, marginTop, marginEnd, marginBottom, marginStart], + ); - const borderBox = ( - - ); + const borderBox = ( + + ); - const content = ( - + {/** Start */} + - {/** Start */} - - - - - {/** Middle */} - - - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {typeof children === 'string' ? ( - - {children} - - ) : ( - children - )} - - {typeof label === 'string' ? ( - - {label} + + + + {/** Middle */} + + + {typeof title === 'string' ? ( + + {title} ) : ( - label + title + )} + {typeof children === 'string' ? ( + + {children} + + ) : ( + children )} - {/** Actions */} - {(!!clonedPrimaryAction || !!clonedSecondaryAction) && ( - - {clonedPrimaryAction} - {clonedSecondaryAction} - + {typeof label === 'string' ? ( + + {label} + + ) : ( + label )} - {/** Dismissable action */} - {showDismiss && ( - - - - - + {/** Actions */} + {(!!clonedPrimaryAction || !!clonedSecondaryAction) && ( + + {clonedPrimaryAction} + {clonedSecondaryAction} + )} - - ); + + {/** Dismissable action */} + {showDismiss && ( + + + + + + )} + + ); - return ( - - {showDismiss ? ( - - {content} - - ) : ( - content - )} - {styleVariant === 'global' && borderBox} - - ); - }), -); + return ( + + {showDismiss ? ( + + {content} + + ) : ( + content + )} + {styleVariant === 'global' && borderBox} + + ); +}); diff --git a/packages/mobile/src/buttons/Button.tsx b/packages/mobile/src/buttons/Button.tsx index 2a2612ec14..72384c69bc 100644 --- a/packages/mobile/src/buttons/Button.tsx +++ b/packages/mobile/src/buttons/Button.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, isValidElement, memo, useCallback, useMemo } from 'react'; +import React, { isValidElement, memo, useCallback, useMemo } from 'react'; import { type PressableStateCallbackType, StyleSheet, type View } from 'react-native'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; import type { @@ -89,172 +89,174 @@ export type ButtonBaseProps = SharedProps & export type ButtonProps = ButtonBaseProps; -export const Button = memo( - forwardRef(function Button(_props: ButtonProps, ref: React.ForwardedRef) { - const mergedProps = useComponentConfig('Button', _props); - const { - variant = 'primary', - loading, - progressCircleSize = defaultProgressCircleSize, - transparent, - block, - compact, - children, - start, - startIcon, - startIconActive, - end, - endIcon, - endIconActive, - flush, - noScaleOnPress, - numberOfLines = 1, - font = 'headline', - fontFamily, - fontSize, - fontWeight, - lineHeight, - background, - color, - style, - wrapperStyles, - feedback = compact ? 'light' : 'normal', - borderColor, - borderWidth = 0, // remove Pressable's default transparent border - borderRadius = compact ? 700 : 900, - accessibilityLabel, - accessibilityHint, - padding, - paddingStart, - paddingEnd, - paddingTop, - paddingBottom, - paddingX = compact ? 2 : 4, - paddingY = compact ? 1 : 2, - ...props - } = mergedProps; - const theme = useTheme(); - const iconSize = compact ? 's' : 'm'; - const hasIcon = Boolean(startIcon || endIcon); +export const Button = memo(function Button({ + ref, + ..._props +}: ButtonProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('Button', _props); + const { + variant = 'primary', + loading, + progressCircleSize = defaultProgressCircleSize, + transparent, + block, + compact, + children, + start, + startIcon, + startIconActive, + end, + endIcon, + endIconActive, + flush, + noScaleOnPress, + numberOfLines = 1, + font = 'headline', + fontFamily, + fontSize, + fontWeight, + lineHeight, + background, + color, + style, + wrapperStyles, + feedback = compact ? 'light' : 'normal', + borderColor, + borderWidth = 0, // remove Pressable's default transparent border + borderRadius = compact ? 700 : 900, + accessibilityLabel, + accessibilityHint, + padding, + paddingStart, + paddingEnd, + paddingTop, + paddingBottom, + paddingX = compact ? 2 : 4, + paddingY = compact ? 1 : 2, + ...props + } = mergedProps; + const theme = useTheme(); + const iconSize = compact ? 's' : 'm'; + const hasIcon = Boolean(startIcon || endIcon); - const variantMap = transparent ? transparentVariants : variants; + const variantMap = transparent ? transparentVariants : variants; - const variantStyle = variantMap[variant]; + const variantStyle = variantMap[variant]; - const colorValue = color ?? variantStyle.color; - const backgroundValue = background ?? variantStyle.background; - const borderColorValue = borderColor ?? variantStyle.borderColor; + const colorValue = color ?? variantStyle.color; + const backgroundValue = background ?? variantStyle.background; + const borderColorValue = borderColor ?? variantStyle.borderColor; - const sizingStyle = block ? styles.block : styles.inline; - const justifyContent = flush ? 'flex-start' : hasIcon ? 'space-between' : 'center'; + const sizingStyle = block ? styles.block : styles.inline; + const justifyContent = flush ? 'flex-start' : hasIcon ? 'space-between' : 'center'; - const flushMargin = flush ? (-paddingX as NegativeSpace) : undefined; + const flushMargin = flush ? (-paddingX as NegativeSpace) : undefined; - const pressableStyle = useCallback( - (state: PressableStateCallbackType) => [ - sizingStyle, - typeof style === 'function' ? style(state) : style, - ], - [sizingStyle, style], - ); + const pressableStyle = useCallback( + (state: PressableStateCallbackType) => [ + sizingStyle, + typeof style === 'function' ? style(state) : style, + ], + [sizingStyle, style], + ); - const childrenNode = useMemo( - () => - isValidElement(children) && - Boolean((children.props as Record).children) ? ( - children - ) : ( - - {children} - - ), - [children, colorValue, font, fontFamily, fontSize, fontWeight, lineHeight, numberOfLines], - ); + const childrenNode = useMemo( + () => + isValidElement(children) && Boolean((children.props as Record).children) ? ( + children + ) : ( + + {children} + + ), + [children, colorValue, font, fontFamily, fontSize, fontWeight, lineHeight, numberOfLines], + ); - return ( - + - - {loading ? ( - - ) : ( - <> - {start ?? - (startIcon ? ( - - ) : null)} - {childrenNode} + {loading ? ( + + ) : ( + <> + {start ?? + (startIcon ? ( + + ) : null)} + {childrenNode} - {end ?? - (endIcon ? ( - - ) : null)} - - )} - - - ); - }), -); + {end ?? + (endIcon ? ( + + ) : null)} + + )} + + + ); +}); Button.displayName = 'Button'; diff --git a/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx b/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx index 8c9fcf6da7..1627e1c86e 100644 --- a/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx +++ b/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; @@ -8,61 +8,59 @@ import { Text } from '../typography/Text'; import type { SlideButtonBackgroundProps } from './SlideButton'; export const DefaultSlideButtonBackground = memo( - forwardRef( - ( - { - progress, - uncheckedLabel, - disabled, - compact, - style, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - }, - ref, - ) => { - const horizontalPadding = compact ? 7 : 9; + ({ + ref, + progress, + uncheckedLabel, + disabled, + compact, + style, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + }: SlideButtonBackgroundProps & { + ref?: React.Ref; + }) => { + const horizontalPadding = compact ? 7 : 9; - const animatedStyle = useAnimatedStyle( - () => ({ opacity: disabled ? 0.5 : 1 - progress.value }), - [progress, disabled], - ); + const animatedStyle = useAnimatedStyle( + () => ({ opacity: disabled ? 0.5 : 1 - progress.value }), + [progress, disabled], + ); - return ( - - - {typeof uncheckedLabel !== 'string' ? ( - uncheckedLabel - ) : ( - - {uncheckedLabel} - - )} - - - ); - }, - ), + return ( + + + {typeof uncheckedLabel !== 'string' ? ( + uncheckedLabel + ) : ( + + {uncheckedLabel} + + )} + + + ); + }, ); diff --git a/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx b/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx index 399a669e41..f21ad384cd 100644 --- a/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx +++ b/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useEffect, useMemo } from 'react'; +import React, { memo, useEffect, useMemo } from 'react'; import { StyleSheet } from 'react-native'; import type { View } from 'react-native'; import Animated, { @@ -120,85 +120,83 @@ export const SlideButtonHandleUnchecked = memo( ); export const DefaultSlideButtonHandle = memo( - forwardRef( - ( - { - checked, - compact, - disabled, - style, - variant = 'primary', - startUncheckedNode, - endCheckedNode, - checkedLabel, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - ...props - }, - ref, - ) => { - const backgroundColor = variants[variant].background; - - const checkedOpacity = useSharedValue(checked ? 1 : 0); - const uncheckedOpacity = useSharedValue(checked ? 0 : 1); - - useEffect(() => { - if (checked) { - uncheckedOpacity.value = withSpring(0, slideButtonSpringConfig); - checkedOpacity.value = withDelay(100, withSpring(1, slideButtonSpringConfig)); - } else { - checkedOpacity.value = 0; - uncheckedOpacity.value = withDelay(100, withSpring(1, slideButtonSpringConfig)); - } - }, [checked, checkedOpacity, uncheckedOpacity]); - - const containerStyle = useMemo(() => [styles.base, style], [style]); - const animatedCheckedStyle = useAnimatedStyle( - () => ({ opacity: checkedOpacity.value }), - [checkedOpacity], - ); - const animatedUncheckedStyle = useAnimatedStyle( - () => ({ opacity: uncheckedOpacity.value }), - [uncheckedOpacity], - ); - - return ( - - - - - - - - - ); - }, - ), + ({ + ref, + checked, + compact, + disabled, + style, + variant = 'primary', + startUncheckedNode, + endCheckedNode, + checkedLabel, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + ...props + }: SlideButtonHandleProps & { + ref?: React.Ref; + }) => { + const backgroundColor = variants[variant].background; + + const checkedOpacity = useSharedValue(checked ? 1 : 0); + const uncheckedOpacity = useSharedValue(checked ? 0 : 1); + + useEffect(() => { + if (checked) { + uncheckedOpacity.value = withSpring(0, slideButtonSpringConfig); + checkedOpacity.value = withDelay(100, withSpring(1, slideButtonSpringConfig)); + } else { + checkedOpacity.value = 0; + uncheckedOpacity.value = withDelay(100, withSpring(1, slideButtonSpringConfig)); + } + }, [checked, checkedOpacity, uncheckedOpacity]); + + const containerStyle = useMemo(() => [styles.base, style], [style]); + const animatedCheckedStyle = useAnimatedStyle( + () => ({ opacity: checkedOpacity.value }), + [checkedOpacity], + ); + const animatedUncheckedStyle = useAnimatedStyle( + () => ({ opacity: uncheckedOpacity.value }), + [uncheckedOpacity], + ); + + return ( + + + + + + + + + ); + }, ); diff --git a/packages/mobile/src/buttons/IconButton.tsx b/packages/mobile/src/buttons/IconButton.tsx index 1c93a30545..e3e8ddcf60 100644 --- a/packages/mobile/src/buttons/IconButton.tsx +++ b/packages/mobile/src/buttons/IconButton.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo } from 'react'; +import { memo } from 'react'; import { type StyleProp, type TextStyle, type View, type ViewStyle } from 'react-native'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; import type { @@ -52,7 +52,12 @@ export type IconButtonBaseProps = SharedProps & export type IconButtonProps = IconButtonBaseProps; export const IconButton = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: IconButtonProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('IconButton', _props); const { name, @@ -132,7 +137,7 @@ export const IconButton = memo( )} ); - }), + }, ); IconButton.displayName = 'IconButton'; diff --git a/packages/mobile/src/buttons/IconCounterButton.tsx b/packages/mobile/src/buttons/IconCounterButton.tsx index 2d7108e4ae..c74bb70c10 100644 --- a/packages/mobile/src/buttons/IconCounterButton.tsx +++ b/packages/mobile/src/buttons/IconCounterButton.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { PressableStateCallbackType, StyleProp, TextStyle, View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { IconSize, ValidateProps } from '@coinbase/cds-common/types'; @@ -41,68 +41,68 @@ export type IconCounterButtonProps = IconCounterButtonBaseProps & }; }; -export const IconCounterButton = memo( - forwardRef(function IconCounterButton( - _props: IconCounterButtonProps, - ref: React.ForwardedRef, - ) { - const mergedProps = useComponentConfig('IconCounterButton', _props); - const { - icon, - size = 's', - active, - count = 0, - color = 'fg', - dangerouslySetColor, - styles, - style, - ...props - } = mergedProps; +export const IconCounterButton = memo(function IconCounterButton({ + ref, + ..._props +}: IconCounterButtonProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('IconCounterButton', _props); + const { + icon, + size = 's', + active, + count = 0, + color = 'fg', + dangerouslySetColor, + styles, + style, + ...props + } = mergedProps; - const rootStyleOverride = styles?.root; + const rootStyleOverride = styles?.root; - const rootStyle = useMemo(() => { - if (typeof style === 'function' || typeof rootStyleOverride === 'function') { - return (state: PressableStateCallbackType) => { - const baseStyle = typeof style === 'function' ? style(state) : style; - const rootOverride = - typeof rootStyleOverride === 'function' ? rootStyleOverride(state) : rootStyleOverride; - return [baseStyle, rootOverride]; - }; - } - return [style, rootStyleOverride]; - }, [rootStyleOverride, style]); + const rootStyle = useMemo(() => { + if (typeof style === 'function' || typeof rootStyleOverride === 'function') { + return (state: PressableStateCallbackType) => { + const baseStyle = typeof style === 'function' ? style(state) : style; + const rootOverride = + typeof rootStyleOverride === 'function' ? rootStyleOverride(state) : rootStyleOverride; + return [baseStyle, rootOverride]; + }; + } + return [style, rootStyleOverride]; + }, [rootStyleOverride, style]); - return ( - - >)} - > - - {typeof icon === 'string' ? ( - - ) : ( - icon - )} - {count > 0 ? ( - - {formatCount(count)} - - ) : null} - - - ); - }), -); + return ( + + >)} + > + + {typeof icon === 'string' ? ( + + ) : ( + icon + )} + {count > 0 ? ( + + {formatCount(count)} + + ) : null} + + + ); +}); diff --git a/packages/mobile/src/buttons/SlideButton.tsx b/packages/mobile/src/buttons/SlideButton.tsx index b8f9b52cc5..99000249ee 100644 --- a/packages/mobile/src/buttons/SlideButton.tsx +++ b/packages/mobile/src/buttons/SlideButton.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useId, useMemo } from 'react'; +import React, { memo, useCallback, useEffect, useId, useMemo } from 'react'; import { type AccessibilityActionEvent, type StyleProp, View, type ViewStyle } from 'react-native'; import type { ForwardedRef } from 'react'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; @@ -163,7 +163,12 @@ export type SlideButtonBaseProps = Omit & { export type SlideButtonProps = SlideButtonBaseProps; export const SlideButton = memo( - forwardRef((_props: SlideButtonProps, ref: ForwardedRef) => { + ({ + ref, + ..._props + }: SlideButtonProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('SlideButton', _props); const { checked, @@ -352,5 +357,5 @@ export const SlideButton = memo( ); - }), + }, ); diff --git a/packages/mobile/src/cards/CardGroup.tsx b/packages/mobile/src/cards/CardGroup.tsx index 7f9135d6fd..f5e2311bd8 100644 --- a/packages/mobile/src/cards/CardGroup.tsx +++ b/packages/mobile/src/cards/CardGroup.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import { gutter } from '@coinbase/cds-common/tokens/sizing'; @@ -22,33 +22,31 @@ export type CardGroupRenderItem = RenderGroupItem; * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v10 */ -export const CardGroup = memo( - forwardRef(function CardGroup( - { - accessibilityLabel, - accessibilityHint = accessibilityLabel, - children, - direction = 'vertical', - divider = Divider, - marginX = direction === 'horizontal' ? 0 : (-gutter as -3), - ...props - }, - ref, - ) { - return ( - - {children} - - ); - }), -); +export const CardGroup = memo(function CardGroup({ + ref, + accessibilityLabel, + accessibilityHint = accessibilityLabel, + children, + direction = 'vertical', + divider = Divider, + marginX = direction === 'horizontal' ? 0 : (-gutter as -3), + ...props +}: CardGroupProps & { + ref?: React.Ref; +}) { + return ( + + {children} + + ); +}); CardGroup.displayName = 'CardGroup'; diff --git a/packages/mobile/src/cards/CardRoot.tsx b/packages/mobile/src/cards/CardRoot.tsx index 1009dac5e6..fc10e99032 100644 --- a/packages/mobile/src/cards/CardRoot.tsx +++ b/packages/mobile/src/cards/CardRoot.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import { HStack } from '../layout/HStack'; @@ -27,14 +27,21 @@ export type CardRootProps = CardRootBaseProps & * When `renderAsPressable` is true, it renders as a Pressable component. */ export const CardRoot = memo( - forwardRef(({ children, renderAsPressable, ...props }, ref) => { + ({ + ref, + children, + renderAsPressable, + ...props + }: CardRootProps & { + ref?: React.Ref; + }) => { const Component = renderAsPressable ? Pressable : HStack; return ( {children} ); - }), + }, ); CardRoot.displayName = 'CardRoot'; diff --git a/packages/mobile/src/cards/ContentCard/ContentCard.tsx b/packages/mobile/src/cards/ContentCard/ContentCard.tsx index 178166f06f..fb9ac17d47 100644 --- a/packages/mobile/src/cards/ContentCard/ContentCard.tsx +++ b/packages/mobile/src/cards/ContentCard/ContentCard.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import { contentCardMaxWidth, contentCardMinWidth } from '@coinbase/cds-common/tokens/card'; @@ -8,33 +8,31 @@ export type ContentCardBaseProps = VStackProps; export type ContentCardProps = ContentCardBaseProps; -export const ContentCard = memo( - forwardRef(function ContentCard( - { - testID, - children, - maxWidth = contentCardMaxWidth, - minWidth = contentCardMinWidth, - borderRadius = 500, - padding = 2, - gap = 2, - ...props - }: ContentCardProps, - ref: React.ForwardedRef, - ) { - return ( - - {children} - - ); - }), -); +export const ContentCard = memo(function ContentCard({ + ref, + testID, + children, + maxWidth = contentCardMaxWidth, + minWidth = contentCardMinWidth, + borderRadius = 500, + padding = 2, + gap = 2, + ...props +}: ContentCardProps & { + ref?: React.Ref; +}) { + return ( + + {children} + + ); +}); diff --git a/packages/mobile/src/cards/ContentCard/ContentCardBody.tsx b/packages/mobile/src/cards/ContentCard/ContentCardBody.tsx index 2171f08084..ae067bcbd1 100644 --- a/packages/mobile/src/cards/ContentCard/ContentCardBody.tsx +++ b/packages/mobile/src/cards/ContentCard/ContentCardBody.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import { type SharedProps } from '@coinbase/cds-common/types'; @@ -65,105 +65,103 @@ export type ContentCardBodyBaseProps = SharedProps & { export type ContentCardBodyProps = ContentCardBodyBaseProps & VStackProps; -export const ContentCardBody = memo( - forwardRef(function ContentCardBody( - { - body, - label, - media, - mediaPosition = 'top', - mediaPlacement = mapMediaPositionToMediaPlacement[mediaPosition], - title, - description = body, - children, - gap = 1, - testID, - style, - styles, - ...props - }: ContentCardBodyProps, - ref: React.ForwardedRef, - ) { - const hasMedia = !!media; - const isHorizontal = mediaPlacement === 'start' || mediaPlacement === 'end'; - const isMediaFirst = hasMedia && (mediaPlacement === 'top' || mediaPlacement === 'start'); - const isMediaLast = hasMedia && (mediaPlacement === 'bottom' || mediaPlacement === 'end'); +export const ContentCardBody = memo(function ContentCardBody({ + ref, + body, + label, + media, + mediaPosition = 'top', + mediaPlacement = mapMediaPositionToMediaPlacement[mediaPosition], + title, + description = body, + children, + gap = 1, + testID, + style, + styles, + ...props +}: ContentCardBodyProps & { + ref?: React.Ref; +}) { + const hasMedia = !!media; + const isHorizontal = mediaPlacement === 'start' || mediaPlacement === 'end'; + const isMediaFirst = hasMedia && (mediaPlacement === 'top' || mediaPlacement === 'start'); + const isMediaLast = hasMedia && (mediaPlacement === 'bottom' || mediaPlacement === 'end'); - const titleNode = useMemo(() => { - if (typeof title === 'string') { - return ( - - {title} - - ); - } - return title; - }, [title]); - - const descriptionNode = useMemo(() => { - if (typeof description === 'string') { - return ( - - {description} - - ); - } - return description; - }, [description]); - - const labelNode = useMemo(() => { - if (typeof label === 'string') { - return {label}; - } - return label; - }, [label]); + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); - const textNode = useMemo(() => { - if (!titleNode && !descriptionNode && !labelNode) { - return null; - } + const descriptionNode = useMemo(() => { + if (typeof description === 'string') { return ( - - {titleNode} - {descriptionNode} - {labelNode} - + + {description} + ); - }, [titleNode, descriptionNode, labelNode, isHorizontal, styles?.textContainer]); + } + return description; + }, [description]); - const mediaBox = isHorizontal ? ( - - {media} - - ) : ( - - {media} - - ); + const labelNode = useMemo(() => { + if (typeof label === 'string') { + return {label}; + } + return label; + }, [label]); + const textNode = useMemo(() => { + if (!titleNode && !descriptionNode && !labelNode) { + return null; + } return ( - - {(mediaBox || textNode) && ( - - {isMediaFirst && mediaBox} - {textNode} - {isMediaLast && mediaBox} - - )} - {children} + + {titleNode} + {descriptionNode} + {labelNode} ); - }), -); + }, [titleNode, descriptionNode, labelNode, isHorizontal, styles?.textContainer]); + + const mediaBox = isHorizontal ? ( + + {media} + + ) : ( + + {media} + + ); + + return ( + + {(mediaBox || textNode) && ( + + {isMediaFirst && mediaBox} + {textNode} + {isMediaLast && mediaBox} + + )} + {children} + + ); +}); diff --git a/packages/mobile/src/cards/ContentCard/ContentCardFooter.tsx b/packages/mobile/src/cards/ContentCard/ContentCardFooter.tsx index 957876296c..5c2a913088 100644 --- a/packages/mobile/src/cards/ContentCard/ContentCardFooter.tsx +++ b/packages/mobile/src/cards/ContentCard/ContentCardFooter.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common/types'; @@ -12,14 +12,16 @@ export type ContentCardFooterBaseProps = SharedProps & { export type ContentCardFooterProps = ContentCardFooterBaseProps & HStackProps; export const ContentCardFooter = memo( - forwardRef( - ( - { children, justifyContent = 'space-between', ...props }: ContentCardFooterProps, - ref: React.ForwardedRef, - ) => ( - - {children} - - ), + ({ + ref, + children, + justifyContent = 'space-between', + ...props + }: ContentCardFooterProps & { + ref?: React.Ref; + }) => ( + + {children} + ), ); diff --git a/packages/mobile/src/cards/ContentCard/ContentCardHeader.tsx b/packages/mobile/src/cards/ContentCard/ContentCardHeader.tsx index 5226fe2ffc..6def024c6f 100644 --- a/packages/mobile/src/cards/ContentCard/ContentCardHeader.tsx +++ b/packages/mobile/src/cards/ContentCard/ContentCardHeader.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common/types'; @@ -43,94 +43,92 @@ export type ContentCardHeaderBaseProps = SharedProps & { export type ContentCardHeaderProps = ContentCardHeaderBaseProps & HStackProps; -export const ContentCardHeader = memo( - forwardRef(function ContentCardHeader( - { - avatar, - title, - meta, - end, - subtitle = meta, - actions = end, - thumbnail, - gap = 1.5, - testID, - styles, - style, - ...props - }: ContentCardHeaderProps, - ref: React.ForwardedRef, - ) { - const titleNode = useMemo(() => { - if (typeof title === 'string') { - return ( - - {title} - - ); - } - return title; - }, [title]); +export const ContentCardHeader = memo(function ContentCardHeader({ + ref, + avatar, + title, + meta, + end, + subtitle = meta, + actions = end, + thumbnail, + gap = 1.5, + testID, + styles, + style, + ...props +}: ContentCardHeaderProps & { + ref?: React.Ref; +}) { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); - const subtitleNode = useMemo(() => { - if (typeof subtitle === 'string') { - return ( - - {subtitle} - - ); - } - return subtitle; - }, [subtitle]); + const subtitleNode = useMemo(() => { + if (typeof subtitle === 'string') { + return ( + + {subtitle} + + ); + } + return subtitle; + }, [subtitle]); - const thumbnailNode = useMemo(() => { - // Use new thumbnail prop if provided - if (thumbnail) return thumbnail; - // Fallback to deprecated avatar prop (supports string for backward compatibility) - if (typeof avatar === 'string') { - return ( - - ); - } - return avatar; - }, [thumbnail, avatar, title]); + const thumbnailNode = useMemo(() => { + // Use new thumbnail prop if provided + if (thumbnail) return thumbnail; + // Fallback to deprecated avatar prop (supports string for backward compatibility) + if (typeof avatar === 'string') { + return ( + + ); + } + return avatar; + }, [thumbnail, avatar, title]); - return ( + return ( + - - {thumbnailNode} - - {titleNode} - {subtitleNode} - - - {actions} + {titleNode} + {subtitleNode} + - ); - }), -); + {actions} + + ); +}); diff --git a/packages/mobile/src/cards/MediaCard/index.tsx b/packages/mobile/src/cards/MediaCard/index.tsx index b6fd3c9e09..27abe4a44e 100644 --- a/packages/mobile/src/cards/MediaCard/index.tsx +++ b/packages/mobile/src/cards/MediaCard/index.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import type { PressableStateCallbackType, StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common'; @@ -23,36 +23,34 @@ const mediaCardContainerProps = { }; export const MediaCard = memo( - forwardRef( - ( - { - title, - subtitle, - description, - thumbnail, - media, - mediaPlacement = 'end', - style, - styles: { root: rootStyle, ...layoutStyles } = {}, - ...props - }, - ref, - ) => { - return ( - - - - ); - }, - ), + ({ + ref, + title, + subtitle, + description, + thumbnail, + media, + mediaPlacement = 'end', + style, + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }: MediaCardProps & { + ref?: React.Ref; + }) => { + return ( + + + + ); + }, ); MediaCard.displayName = 'MediaCard'; diff --git a/packages/mobile/src/cards/MessagingCard/index.tsx b/packages/mobile/src/cards/MessagingCard/index.tsx index 9f26b85aa5..dcc4101329 100644 --- a/packages/mobile/src/cards/MessagingCard/index.tsx +++ b/packages/mobile/src/cards/MessagingCard/index.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo } from 'react'; +import { memo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common'; @@ -22,56 +22,54 @@ const messagingCardContainerProps = { }; export const MessagingCard = memo( - forwardRef( - ( - { - type, - title, - description, - tag, - action, - onActionButtonPress, - actionButtonAccessibilityLabel, - dismissButton, - onDismissButtonPress, - dismissButtonAccessibilityLabel, - mediaPlacement, - media, - style, - styles: { root: rootStyle, ...layoutStyles } = {}, - ...props - }, - ref, - ) => { - const background = type === 'upsell' ? 'bgPrimary' : 'bgAlternate'; - return ( - - - - ); - }, - ), + ({ + ref, + type, + title, + description, + tag, + action, + onActionButtonPress, + actionButtonAccessibilityLabel, + dismissButton, + onDismissButtonPress, + dismissButtonAccessibilityLabel, + mediaPlacement, + media, + style, + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }: MessagingCardProps & { + ref?: React.Ref; + }) => { + const background = type === 'upsell' ? 'bgPrimary' : 'bgAlternate'; + return ( + + + + ); + }, ); MessagingCard.displayName = 'MessagingCard'; diff --git a/packages/mobile/src/carousel/Carousel.tsx b/packages/mobile/src/carousel/Carousel.tsx index 0575703a82..c2f0f0d5ae 100644 --- a/packages/mobile/src/carousel/Carousel.tsx +++ b/packages/mobile/src/carousel/Carousel.tsx @@ -1,5 +1,4 @@ import React, { - forwardRef, memo, useCallback, useEffect, @@ -547,598 +546,570 @@ const animationConfig = { }; export const Carousel = memo( - forwardRef( - (_props: CarouselProps, ref: React.ForwardedRef) => { - const mergedProps = useComponentConfig('Carousel', _props); - const { - children, - title, - hideNavigation, - hidePagination, - paginationVariant, - drag = 'snap', - snapMode = 'page', - NavigationComponent = DefaultCarouselNavigation, - PaginationComponent = DefaultCarouselPagination, - style, - styles, - nextPageAccessibilityLabel, - previousPageAccessibilityLabel, - startAutoplayAccessibilityLabel, - stopAutoplayAccessibilityLabel, - paginationAccessibilityLabel, - onChangePage, - onDragStart, - onDragEnd, - loop, - autoplay, - autoplayInterval = 3000, - ...props - } = mergedProps; - const carouselScrollX = useRef(0); - - const animationApi = useSpring({ - x: carouselScrollX.current, - config: animationConfig, - }); - - const [activePageIndex, setActivePageIndex] = useState(0); - const [containerSize, onLayout] = useLayout(); - const [carouselItemRects, setCarouselItemRects] = useState<{ - [itemId: string]: Rect; - }>({}); - const [visibleCarouselItems, setVisibleCarouselItems] = useState>(new Set()); - - const isDragEnabled = drag !== 'none'; - - const updateActivePageIndex = useCallback( - (newPageIndexOrUpdater: number | ((prevIndex: number) => number)) => { - setActivePageIndex((prevIndex) => { - const newPageIndex = - typeof newPageIndexOrUpdater === 'function' - ? newPageIndexOrUpdater(prevIndex) - : newPageIndexOrUpdater; - - if (prevIndex !== newPageIndex) onChangePage?.(newPageIndex); - - return newPageIndex; - }); - }, - [onChangePage], - ); - - const contentWidth = useMemo(() => { - if (Object.keys(carouselItemRects).length === 0) return 0; - const items = getItemOffsets(carouselItemRects); - const lastItem = items[items.length - 1]; - return lastItem.x + lastItem.width; - }, [carouselItemRects]); - - const maxScrollOffset = Math.max(0, contentWidth - containerSize.width); - const hasCalculatedDimensions = contentWidth > 0 && containerSize.width > 0; - - // Calculate gap between items (needed for loopLength to maintain consistent spacing at wrap seam) - const gap = useMemo(() => { - if (Object.keys(carouselItemRects).length < 2) return 0; - const items = getItemOffsets(carouselItemRects); - const firstItemEnd = items[0].x + items[0].width; - const secondItemStart = items[1].x; - return Math.max(0, secondItemStart - firstItemEnd); - }, [carouselItemRects]); - - const shouldLoop = useMemo( - () => loop && hasCalculatedDimensions && maxScrollOffset > 0, - [loop, hasCalculatedDimensions, maxScrollOffset], - ); - - const loopLength = useMemo(() => { - if (!shouldLoop) return 0; - return contentWidth + gap; - }, [shouldLoop, contentWidth, gap]); - - const isLoopingActive = shouldLoop && loopLength > 0; - - // Calculate how many items to clone for each direction (enough to fill viewport) - const cloneCounts = useMemo(() => { - if ( - !shouldLoop || - Object.keys(carouselItemRects).length === 0 || - containerSize.width === 0 - ) { - return { forward: 0, backward: 0 }; + ({ + ref, + ..._props + }: CarouselProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('Carousel', _props); + const { + children, + title, + hideNavigation, + hidePagination, + paginationVariant, + drag = 'snap', + snapMode = 'page', + NavigationComponent = DefaultCarouselNavigation, + PaginationComponent = DefaultCarouselPagination, + style, + styles, + nextPageAccessibilityLabel, + previousPageAccessibilityLabel, + startAutoplayAccessibilityLabel, + stopAutoplayAccessibilityLabel, + paginationAccessibilityLabel, + onChangePage, + onDragStart, + onDragEnd, + loop, + autoplay, + autoplayInterval = 3000, + ...props + } = mergedProps; + const carouselScrollX = useRef(0); + + const animationApi = useSpring({ + x: carouselScrollX.current, + config: animationConfig, + }); + + const [activePageIndex, setActivePageIndex] = useState(0); + const [containerSize, onLayout] = useLayout(); + const [carouselItemRects, setCarouselItemRects] = useState<{ + [itemId: string]: Rect; + }>({}); + const [visibleCarouselItems, setVisibleCarouselItems] = useState>(new Set()); + + const isDragEnabled = drag !== 'none'; + + const updateActivePageIndex = useCallback( + (newPageIndexOrUpdater: number | ((prevIndex: number) => number)) => { + setActivePageIndex((prevIndex) => { + const newPageIndex = + typeof newPageIndexOrUpdater === 'function' + ? newPageIndexOrUpdater(prevIndex) + : newPageIndexOrUpdater; + + if (prevIndex !== newPageIndex) onChangePage?.(newPageIndex); + + return newPageIndex; + }); + }, + [onChangePage], + ); + + const contentWidth = useMemo(() => { + if (Object.keys(carouselItemRects).length === 0) return 0; + const items = getItemOffsets(carouselItemRects); + const lastItem = items[items.length - 1]; + return lastItem.x + lastItem.width; + }, [carouselItemRects]); + + const maxScrollOffset = Math.max(0, contentWidth - containerSize.width); + const hasCalculatedDimensions = contentWidth > 0 && containerSize.width > 0; + + // Calculate gap between items (needed for loopLength to maintain consistent spacing at wrap seam) + const gap = useMemo(() => { + if (Object.keys(carouselItemRects).length < 2) return 0; + const items = getItemOffsets(carouselItemRects); + const firstItemEnd = items[0].x + items[0].width; + const secondItemStart = items[1].x; + return Math.max(0, secondItemStart - firstItemEnd); + }, [carouselItemRects]); + + const shouldLoop = useMemo( + () => loop && hasCalculatedDimensions && maxScrollOffset > 0, + [loop, hasCalculatedDimensions, maxScrollOffset], + ); + + const loopLength = useMemo(() => { + if (!shouldLoop) return 0; + return contentWidth + gap; + }, [shouldLoop, contentWidth, gap]); + + const isLoopingActive = shouldLoop && loopLength > 0; + + // Calculate how many items to clone for each direction (enough to fill viewport) + const cloneCounts = useMemo(() => { + if (!shouldLoop || Object.keys(carouselItemRects).length === 0 || containerSize.width === 0) { + return { forward: 0, backward: 0 }; + } + const items = getItemOffsets(carouselItemRects); + return { + forward: getCloneCount(items, containerSize.width), + backward: getCloneCount([...items].reverse(), containerSize.width), + }; + }, [shouldLoop, carouselItemRects, containerSize.width]); + + const updateVisibleCarouselItems = useCallback( + (scrollOffset: number) => { + if (containerSize.width === 0) { + setVisibleCarouselItems(new Set()); + return; } - const items = getItemOffsets(carouselItemRects); - return { - forward: getCloneCount(items, containerSize.width), - backward: getCloneCount([...items].reverse(), containerSize.width), - }; - }, [shouldLoop, carouselItemRects, containerSize.width]); - - const updateVisibleCarouselItems = useCallback( - (scrollOffset: number) => { - if (containerSize.width === 0) { - setVisibleCarouselItems(new Set()); - return; - } - // For original items: wrap the offset to check visibility within one cycle - const adjustedOffset = isLoopingActive - ? ((scrollOffset % loopLength) + loopLength) % loopLength - : scrollOffset; + // For original items: wrap the offset to check visibility within one cycle + const adjustedOffset = isLoopingActive + ? ((scrollOffset % loopLength) + loopLength) % loopLength + : scrollOffset; - const visibleItems = getVisibleItems( - carouselItemRects, - containerSize.width, - adjustedOffset, - ); + const visibleItems = getVisibleItems( + carouselItemRects, + containerSize.width, + adjustedOffset, + ); + + // For clones: check visibility against actual (unwrapped) scroll position + if (isLoopingActive && children) { + const childrenArray = React.Children.toArray(children) as CarouselItemElement[]; + const items = getItemOffsets(carouselItemRects); + const viewportLeft = scrollOffset; + const viewportRight = scrollOffset + containerSize.width; - // For clones: check visibility against actual (unwrapped) scroll position - if (isLoopingActive && children) { - const childrenArray = React.Children.toArray(children) as CarouselItemElement[]; - const items = getItemOffsets(carouselItemRects); - const viewportLeft = scrollOffset; - const viewportRight = scrollOffset + containerSize.width; - - // Check backward clones visibility - const backwardStartIndex = childrenArray.length - cloneCounts.backward; - for (let i = 0; i < cloneCounts.backward; i++) { - const originalIndex = backwardStartIndex + i; - const itemData = items[originalIndex]; - if (itemData) { - const cloneX = itemData.x - loopLength; - const cloneRight = cloneX + itemData.width; - if (cloneX < viewportRight && cloneRight > viewportLeft) { - visibleItems.add(`clone-backward-${childrenArray[originalIndex].props.id}`); - } + // Check backward clones visibility + const backwardStartIndex = childrenArray.length - cloneCounts.backward; + for (let i = 0; i < cloneCounts.backward; i++) { + const originalIndex = backwardStartIndex + i; + const itemData = items[originalIndex]; + if (itemData) { + const cloneX = itemData.x - loopLength; + const cloneRight = cloneX + itemData.width; + if (cloneX < viewportRight && cloneRight > viewportLeft) { + visibleItems.add(`clone-backward-${childrenArray[originalIndex].props.id}`); } } + } - // Check forward clones visibility - for (let i = 0; i < cloneCounts.forward; i++) { - const itemData = items[i]; - if (itemData) { - const cloneX = itemData.x + loopLength; - const cloneRight = cloneX + itemData.width; - if (cloneX < viewportRight && cloneRight > viewportLeft) { - visibleItems.add(`clone-forward-${childrenArray[i].props.id}`); - } + // Check forward clones visibility + for (let i = 0; i < cloneCounts.forward; i++) { + const itemData = items[i]; + if (itemData) { + const cloneX = itemData.x + loopLength; + const cloneRight = cloneX + itemData.width; + if (cloneX < viewportRight && cloneRight > viewportLeft) { + visibleItems.add(`clone-forward-${childrenArray[i].props.id}`); } } } - - setVisibleCarouselItems(visibleItems); - }, - [ - carouselItemRects, - containerSize.width, - isLoopingActive, - loopLength, - children, - cloneCounts, - ], - ); - - // Calculate pages and their offsets based on snapMode - const { totalPages, pageOffsets } = useMemo(() => { - if (!hasCalculatedDimensions || Object.keys(carouselItemRects).length === 0) { - return { totalPages: 0, pageOffsets: [] }; } - let pageOffsets: { totalPages: number; pageOffsets: number[] }; + setVisibleCarouselItems(visibleItems); + }, + [carouselItemRects, containerSize.width, isLoopingActive, loopLength, children, cloneCounts], + ); - if (snapMode === 'item') { - pageOffsets = getSnapItemPageOffsets( - getItemOffsets(carouselItemRects), - containerSize.width, - maxScrollOffset, - shouldLoop, - ); - } else { - pageOffsets = getSnapPageOffsets( - getItemOffsets(carouselItemRects), - containerSize.width, - maxScrollOffset, - ); - } + // Calculate pages and their offsets based on snapMode + const { totalPages, pageOffsets } = useMemo(() => { + if (!hasCalculatedDimensions || Object.keys(carouselItemRects).length === 0) { + return { totalPages: 0, pageOffsets: [] }; + } - updateActivePageIndex((pageIndex) => Math.min(pageIndex, pageOffsets.totalPages - 1)); + let pageOffsets: { totalPages: number; pageOffsets: number[] }; - return pageOffsets; - }, [ - hasCalculatedDimensions, - carouselItemRects, - snapMode, - containerSize.width, - maxScrollOffset, - shouldLoop, - updateActivePageIndex, - ]); + if (snapMode === 'item') { + pageOffsets = getSnapItemPageOffsets( + getItemOffsets(carouselItemRects), + containerSize.width, + maxScrollOffset, + shouldLoop, + ); + } else { + pageOffsets = getSnapPageOffsets( + getItemOffsets(carouselItemRects), + containerSize.width, + maxScrollOffset, + ); + } - const { - isPlaying, - isStopped, - isPaused, - start, - stop, - toggle, + updateActivePageIndex((pageIndex) => Math.min(pageIndex, pageOffsets.totalPages - 1)); + + return pageOffsets; + }, [ + hasCalculatedDimensions, + carouselItemRects, + snapMode, + containerSize.width, + maxScrollOffset, + shouldLoop, + updateActivePageIndex, + ]); + + const { + isPlaying, + isStopped, + isPaused, + start, + stop, + toggle, + reset, + pause, + resume, + getRemainingTime, + addCompletionListener, + } = useCarouselAutoplay({ + enabled: autoplay ?? false, + interval: autoplayInterval, + }); + + const goToPage = useCallback( + (page: number) => { + const newPage = Math.max(0, Math.min(totalPages - 1, page)); + updateActivePageIndex(newPage); + updateVisibleCarouselItems(pageOffsets[newPage]); + + const targetOffset = isLoopingActive + ? findNearestLoopOffset(carouselScrollX.current, [pageOffsets[newPage]], loopLength) + .offset + : pageOffsets[newPage]; + + carouselScrollX.current = targetOffset; + animationApi.x.start({ to: targetOffset, config: animationConfig }); + reset(); + }, + [ + totalPages, + updateActivePageIndex, + updateVisibleCarouselItems, + pageOffsets, + isLoopingActive, + loopLength, + animationApi.x, reset, - pause, - resume, - getRemainingTime, - addCompletionListener, - } = useCarouselAutoplay({ - enabled: autoplay ?? false, - interval: autoplayInterval, + ], + ); + + useImperativeHandle( + ref, + () => ({ + activePageIndex, + totalPages, + goToPage, + }), + [activePageIndex, totalPages, goToPage], + ); + + useEffect(() => { + if (!autoplay || totalPages === 0) return; + + const unsubscribe = addCompletionListener(() => { + const nextPage = wrap(0, totalPages, activePageIndex + 1); + reset(); + goToPage(nextPage); }); + return unsubscribe; + }, [autoplay, addCompletionListener, activePageIndex, totalPages, goToPage, reset]); + + const handleGoNext = useCallback(() => { + const nextPage = shouldLoop ? wrap(0, totalPages, activePageIndex + 1) : activePageIndex + 1; + goToPage(nextPage); + }, [shouldLoop, totalPages, activePageIndex, goToPage]); + + const handleGoPrevious = useCallback(() => { + const prevPage = shouldLoop ? wrap(0, totalPages, activePageIndex - 1) : activePageIndex - 1; + goToPage(prevPage); + }, [shouldLoop, totalPages, activePageIndex, goToPage]); + + const handleDragStart = useCallback(() => { + onDragStart?.(); + pause(); + }, [onDragStart, pause]); + + const handleDragEnd = useCallback(() => { + onDragEnd?.(); + resume(); + }, [onDragEnd, resume]); + + const handleDragTransition = useCallback( + (targetOffsetScroll: number) => { + if (drag === 'none') return targetOffsetScroll; + + if (isLoopingActive) { + const { offset: nearestOffset, index: pageIndex } = findNearestLoopOffset( + targetOffsetScroll, + pageOffsets, + loopLength, + ); - const goToPage = useCallback( - (page: number) => { - const newPage = Math.max(0, Math.min(totalPages - 1, page)); - updateActivePageIndex(newPage); - updateVisibleCarouselItems(pageOffsets[newPage]); + if (pageIndex !== activePageIndex) reset(); - const targetOffset = isLoopingActive - ? findNearestLoopOffset(carouselScrollX.current, [pageOffsets[newPage]], loopLength) - .offset - : pageOffsets[newPage]; + updateActivePageIndex(pageIndex); - carouselScrollX.current = targetOffset; - animationApi.x.start({ to: targetOffset, config: animationConfig }); - reset(); - }, - [ - totalPages, - updateActivePageIndex, - updateVisibleCarouselItems, - pageOffsets, - isLoopingActive, - loopLength, - animationApi.x, - reset, - ], - ); - - useImperativeHandle( - ref, - () => ({ - activePageIndex, - totalPages, - goToPage, - }), - [activePageIndex, totalPages, goToPage], - ); - - useEffect(() => { - if (!autoplay || totalPages === 0) return; - - const unsubscribe = addCompletionListener(() => { - const nextPage = wrap(0, totalPages, activePageIndex + 1); - reset(); - goToPage(nextPage); - }); - return unsubscribe; - }, [autoplay, addCompletionListener, activePageIndex, totalPages, goToPage, reset]); + if (drag === 'snap') { + updateVisibleCarouselItems(pageOffsets[pageIndex]); + return nearestOffset; + } - const handleGoNext = useCallback(() => { - const nextPage = shouldLoop - ? wrap(0, totalPages, activePageIndex + 1) - : activePageIndex + 1; - goToPage(nextPage); - }, [shouldLoop, totalPages, activePageIndex, goToPage]); - - const handleGoPrevious = useCallback(() => { - const prevPage = shouldLoop - ? wrap(0, totalPages, activePageIndex - 1) - : activePageIndex - 1; - goToPage(prevPage); - }, [shouldLoop, totalPages, activePageIndex, goToPage]); - - const handleDragStart = useCallback(() => { - onDragStart?.(); - pause(); - }, [onDragStart, pause]); - - const handleDragEnd = useCallback(() => { - onDragEnd?.(); - resume(); - }, [onDragEnd, resume]); - - const handleDragTransition = useCallback( - (targetOffsetScroll: number) => { - if (drag === 'none') return targetOffsetScroll; - - if (isLoopingActive) { - const { offset: nearestOffset, index: pageIndex } = findNearestLoopOffset( - targetOffsetScroll, - pageOffsets, - loopLength, - ); - - if (pageIndex !== activePageIndex) reset(); - - updateActivePageIndex(pageIndex); - - if (drag === 'snap') { - updateVisibleCarouselItems(pageOffsets[pageIndex]); - return nearestOffset; - } + const currentCycle = Math.floor(targetOffsetScroll / loopLength); + const localOffset = targetOffsetScroll - currentCycle * loopLength; + updateVisibleCarouselItems(localOffset); + return targetOffsetScroll; + } else { + // Non-looping logic with clamping + const clampedScrollOffset = clampWithElasticResistance( + targetOffsetScroll, + maxScrollOffset, + 0, + ); + const closestPageIndex = getNearestPageIndexFromOffset(clampedScrollOffset, pageOffsets); - const currentCycle = Math.floor(targetOffsetScroll / loopLength); - const localOffset = targetOffsetScroll - currentCycle * loopLength; - updateVisibleCarouselItems(localOffset); - return targetOffsetScroll; - } else { - // Non-looping logic with clamping - const clampedScrollOffset = clampWithElasticResistance( - targetOffsetScroll, - maxScrollOffset, - 0, - ); - const closestPageIndex = getNearestPageIndexFromOffset( - clampedScrollOffset, - pageOffsets, - ); - - if (closestPageIndex !== activePageIndex) reset(); - - updateActivePageIndex(closestPageIndex); - - if (drag === 'snap') { - const snapOffset = pageOffsets[closestPageIndex]; - updateVisibleCarouselItems(snapOffset); - return snapOffset; - } + if (closestPageIndex !== activePageIndex) reset(); - updateVisibleCarouselItems(clampedScrollOffset); - return targetOffsetScroll; + updateActivePageIndex(closestPageIndex); + + if (drag === 'snap') { + const snapOffset = pageOffsets[closestPageIndex]; + updateVisibleCarouselItems(snapOffset); + return snapOffset; } - }, - [ - drag, - isLoopingActive, - loopLength, - maxScrollOffset, - pageOffsets, - activePageIndex, - updateVisibleCarouselItems, - updateActivePageIndex, - reset, - ], - ); - - const panGesture = useMemo( - () => - Gesture.Pan() - // Only activate when horizontal movement exceeds threshold - .activeOffsetX([-10, 10]) - // Fail (let parent scroll) when vertical movement exceeds threshold first - .failOffsetY([-10, 10]) - .onStart(() => { - if (!isDragEnabled) return; - handleDragStart(); - }) - .onUpdate(({ translationX }) => { - if (!isDragEnabled) return; - - let newOffset: number; - if (shouldLoop) { - newOffset = carouselScrollX.current - translationX; - } else { - newOffset = clampWithElasticResistance( - carouselScrollX.current - translationX, - maxScrollOffset, - ); - } - animationApi.x.set(newOffset); - }) - .onEnd(({ translationX, velocityX }) => { - if (!isDragEnabled) return; + updateVisibleCarouselItems(clampedScrollOffset); + return targetOffsetScroll; + } + }, + [ + drag, + isLoopingActive, + loopLength, + maxScrollOffset, + pageOffsets, + activePageIndex, + updateVisibleCarouselItems, + updateActivePageIndex, + reset, + ], + ); + + const panGesture = useMemo( + () => + Gesture.Pan() + // Only activate when horizontal movement exceeds threshold + .activeOffsetX([-10, 10]) + // Fail (let parent scroll) when vertical movement exceeds threshold first + .failOffsetY([-10, 10]) + .onStart(() => { + if (!isDragEnabled) return; + handleDragStart(); + }) + .onUpdate(({ translationX }) => { + if (!isDragEnabled) return; + + let newOffset: number; + if (shouldLoop) { + newOffset = carouselScrollX.current - translationX; + } else { + newOffset = clampWithElasticResistance( + carouselScrollX.current - translationX, + maxScrollOffset, + ); + } - let projectedOffset: number; + animationApi.x.set(newOffset); + }) + .onEnd(({ translationX, velocityX }) => { + if (!isDragEnabled) return; - if (shouldLoop) { - projectedOffset = carouselScrollX.current - translationX; - } else { - projectedOffset = clampWithElasticResistance( - carouselScrollX.current - translationX, - maxScrollOffset, - ); - } + let projectedOffset: number; - const power = drag === 'free' ? 0.25 : 0.125; - const momentumDistance = velocityX * power; - - if (shouldLoop) { - projectedOffset = projectedOffset - momentumDistance; - } else { - projectedOffset = clampWithElasticResistance( - projectedOffset - momentumDistance, - maxScrollOffset, - 0, - ); - } + if (shouldLoop) { + projectedOffset = carouselScrollX.current - translationX; + } else { + projectedOffset = clampWithElasticResistance( + carouselScrollX.current - translationX, + maxScrollOffset, + ); + } - const finalOffset = handleDragTransition(projectedOffset); + const power = drag === 'free' ? 0.25 : 0.125; + const momentumDistance = velocityX * power; + + if (shouldLoop) { + projectedOffset = projectedOffset - momentumDistance; + } else { + projectedOffset = clampWithElasticResistance( + projectedOffset - momentumDistance, + maxScrollOffset, + 0, + ); + } - carouselScrollX.current = finalOffset; + const finalOffset = handleDragTransition(projectedOffset); - animationApi.x.start({ - to: finalOffset, - config: { - ...animationConfig, - }, - }); + carouselScrollX.current = finalOffset; - handleDragEnd(); - }) - .runOnJS(true), - [ - isDragEnabled, - shouldLoop, - maxScrollOffset, - animationApi, - drag, - handleDragTransition, - handleDragStart, - handleDragEnd, - ], - ); + animationApi.x.start({ + to: finalOffset, + config: { + ...animationConfig, + }, + }); - const childrenWithClones = useMemo(() => { - if (!loop) return children; + handleDragEnd(); + }) + .runOnJS(true), + [ + isDragEnabled, + shouldLoop, + maxScrollOffset, + animationApi, + drag, + handleDragTransition, + handleDragStart, + handleDragEnd, + ], + ); - const childrenArray = React.Children.toArray(children) as CarouselItemElement[]; - if (childrenArray.length === 0) return children; + const childrenWithClones = useMemo(() => { + if (!loop) return children; - const result: React.ReactNode[] = []; + const childrenArray = React.Children.toArray(children) as CarouselItemElement[]; + if (childrenArray.length === 0) return children; - // Add backward clones (only when we have enough data to position them) - if (isLoopingActive && cloneCounts.backward > 0) { - const items = getItemOffsets(carouselItemRects); - const itemsToCloneBackward = childrenArray.slice(-cloneCounts.backward); + const result: React.ReactNode[] = []; - itemsToCloneBackward.forEach((child, cloneIndex) => { - const originalIndex = childrenArray.length - cloneCounts.backward + cloneIndex; - const itemData = items[originalIndex]; - const cloneId = `clone-backward-${child.props.id}`; - result.push( - - {child.props.children} - , - ); - }); - } + // Add backward clones (only when we have enough data to position them) + if (isLoopingActive && cloneCounts.backward > 0) { + const items = getItemOffsets(carouselItemRects); + const itemsToCloneBackward = childrenArray.slice(-cloneCounts.backward); + + itemsToCloneBackward.forEach((child, cloneIndex) => { + const originalIndex = childrenArray.length - cloneCounts.backward + cloneIndex; + const itemData = items[originalIndex]; + const cloneId = `clone-backward-${child.props.id}`; + result.push( + + {child.props.children} + , + ); + }); + } - // Add original children (always present, never changes structure) - result.push(...childrenArray); + // Add original children (always present, never changes structure) + result.push(...childrenArray); - // Add forward clones (only when we have enough data) - if (isLoopingActive && cloneCounts.forward > 0) { - const items = getItemOffsets(carouselItemRects); - const itemsToCloneForward = childrenArray.slice(0, cloneCounts.forward); - - itemsToCloneForward.forEach((child, cloneIndex) => { - const itemData = items[cloneIndex]; - const cloneId = `clone-forward-${child.props.id}`; - result.push( - - {child.props.children} - , - ); - }); - } + // Add forward clones (only when we have enough data) + if (isLoopingActive && cloneCounts.forward > 0) { + const items = getItemOffsets(carouselItemRects); + const itemsToCloneForward = childrenArray.slice(0, cloneCounts.forward); + + itemsToCloneForward.forEach((child, cloneIndex) => { + const itemData = items[cloneIndex]; + const cloneId = `clone-forward-${child.props.id}`; + result.push( + + {child.props.children} + , + ); + }); + } - return result; - }, [loop, children, isLoopingActive, loopLength, cloneCounts, carouselItemRects]); + return result; + }, [loop, children, isLoopingActive, loopLength, cloneCounts, carouselItemRects]); - const containerStyle = useMemo( - () => [{ flex: 1, overflow: 'hidden' } as const, style, styles?.root], - [style, styles?.root], - ); + const containerStyle = useMemo( + () => [{ flex: 1, overflow: 'hidden' } as const, style, styles?.root], + [style, styles?.root], + ); - const scrollViewStyle = useMemo( - () => [ + const scrollViewStyle = useMemo( + () => [ + { + flex: 1, + }, + styles?.carouselContainer, + ], + [styles?.carouselContainer], + ); + + const animatedStyle = useMemo( + () => ({ + flexDirection: 'row' as const, + ...(styles?.carousel as any), + }), + [styles?.carousel], + ); + + const animatedTransform = useMemo( + () => ({ + transform: [ { - flex: 1, + translateX: animationApi.x.to((value) => { + if (!shouldLoop || !loopLength) return -value; + // Wrap the value to stay within one cycle for visual continuity + // Ensure wrapped is always in range [0, loopLength) + const wrapped = ((value % loopLength) + loopLength) % loopLength; + return -wrapped; + }), }, - styles?.carouselContainer, ], - [styles?.carouselContainer], - ); - - const animatedStyle = useMemo( - () => ({ - flexDirection: 'row' as const, - ...(styles?.carousel as any), - }), - [styles?.carousel], - ); - - const animatedTransform = useMemo( - () => ({ - transform: [ - { - translateX: animationApi.x.to((value) => { - if (!shouldLoop || !loopLength) return -value; - // Wrap the value to stay within one cycle for visual continuity - // Ensure wrapped is always in range [0, loopLength) - const wrapped = ((value % loopLength) + loopLength) % loopLength; - return -wrapped; - }), - }, - ], - }), - [animationApi, shouldLoop, loopLength], - ); - - const registerItem = useCallback( - (id: string, rect: Rect) => { - setCarouselItemRects((prev) => ({ - ...prev, - [id]: rect, - })); - updateVisibleCarouselItems(carouselScrollX.current); - }, - [updateVisibleCarouselItems], - ); - - const unregisterItem = useCallback((id: string) => { - setCarouselItemRects((prev) => { - const newRects = { ...prev }; - delete newRects[id]; - return newRects; - }); - }, []); - - const carouselContextValue: CarouselContextValue = useMemo( - () => ({ - registerItem, - unregisterItem, - visibleCarouselItems, - }), - [registerItem, unregisterItem, visibleCarouselItems], - ); - - const autoplayContextValue = useMemo(() => { - return { - isEnabled: !!autoplay, - isStopped, - isPaused, - isPlaying, - interval: autoplayInterval, - getRemainingTime, - start, - stop, - toggle, - reset, - pause, - resume, - }; - }, [ - autoplay, + }), + [animationApi, shouldLoop, loopLength], + ); + + const registerItem = useCallback( + (id: string, rect: Rect) => { + setCarouselItemRects((prev) => ({ + ...prev, + [id]: rect, + })); + updateVisibleCarouselItems(carouselScrollX.current); + }, + [updateVisibleCarouselItems], + ); + + const unregisterItem = useCallback((id: string) => { + setCarouselItemRects((prev) => { + const newRects = { ...prev }; + delete newRects[id]; + return newRects; + }); + }, []); + + const carouselContextValue: CarouselContextValue = useMemo( + () => ({ + registerItem, + unregisterItem, + visibleCarouselItems, + }), + [registerItem, unregisterItem, visibleCarouselItems], + ); + + const autoplayContextValue = useMemo(() => { + return { + isEnabled: !!autoplay, isStopped, isPaused, isPlaying, - autoplayInterval, + interval: autoplayInterval, getRemainingTime, start, stop, @@ -1146,69 +1117,82 @@ export const Carousel = memo( reset, pause, resume, - ]); - - return ( - - - - {(title || !hideNavigation) && ( - - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {!hideNavigation && ( - = totalPages - 1) - } - disableGoPrevious={totalPages <= 1 || (!shouldLoop && activePageIndex <= 0)} - isAutoplayStopped={isStopped} - nextPageAccessibilityLabel={nextPageAccessibilityLabel} - onGoNext={handleGoNext} - onGoPrevious={handleGoPrevious} - onToggleAutoplay={toggle} - previousPageAccessibilityLabel={previousPageAccessibilityLabel} - startAutoplayAccessibilityLabel={startAutoplayAccessibilityLabel} - stopAutoplayAccessibilityLabel={stopAutoplayAccessibilityLabel} - style={styles?.navigation} - /> - )} - - )} - - - - {childrenWithClones} - - - - {!hidePagination && ( - - )} - - - - ); - }, - ), + }; + }, [ + autoplay, + isStopped, + isPaused, + isPlaying, + autoplayInterval, + getRemainingTime, + start, + stop, + toggle, + reset, + pause, + resume, + ]); + + return ( + + + + {(title || !hideNavigation) && ( + + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + {!hideNavigation && ( + = totalPages - 1) + } + disableGoPrevious={totalPages <= 1 || (!shouldLoop && activePageIndex <= 0)} + isAutoplayStopped={isStopped} + nextPageAccessibilityLabel={nextPageAccessibilityLabel} + onGoNext={handleGoNext} + onGoPrevious={handleGoPrevious} + onToggleAutoplay={toggle} + previousPageAccessibilityLabel={previousPageAccessibilityLabel} + startAutoplayAccessibilityLabel={startAutoplayAccessibilityLabel} + stopAutoplayAccessibilityLabel={stopAutoplayAccessibilityLabel} + style={styles?.navigation} + /> + )} + + )} + + + + {childrenWithClones} + + + + {!hidePagination && ( + + )} + + + + ); + }, ); diff --git a/packages/mobile/src/chips/Chip.tsx b/packages/mobile/src/chips/Chip.tsx index 46d6164443..0bcc060177 100644 --- a/packages/mobile/src/chips/Chip.tsx +++ b/packages/mobile/src/chips/Chip.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, Fragment, memo } from 'react'; +import React, { Fragment, memo } from 'react'; import type { View } from 'react-native'; import { chipMaxWidth } from '@coinbase/cds-common/tokens/chip'; @@ -13,92 +13,95 @@ export type { ChipProps }; /** * This is a basic Chip component used to create all Chip components. */ -export const Chip = memo( - forwardRef(function Chip(_props: ChipProps, ref: React.ForwardedRef) { - const mergedProps = useComponentConfig('Chip', _props); - const { - alignSelf = 'flex-start', - children, - start, - end, - invertColorScheme, - inverted, - maxWidth = chipMaxWidth, - compact, - gap = 1, - paddingX = compact ? 1.5 : 2, - paddingY = compact ? 0.5 : 1, - alignItems = 'center', - justifyContent, - padding, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - numberOfLines = 1, - testID, - contentStyle, - borderRadius = 700, - background = 'bgSecondary', - style, - styles, - onPress, - color = 'fg', - font = compact ? 'label1' : 'headline', - ...props - } = mergedProps; - const WrapperComponent = (invertColorScheme ?? inverted) ? InvertedThemeProvider : Fragment; - const containerProps = { - testID, - background, - borderRadius, - ref, - alignSelf, - style: [style, styles?.root], - }; +export const Chip = memo(function Chip({ + ref, + ..._props +}: ChipProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('Chip', _props); + const { + alignSelf = 'flex-start', + children, + start, + end, + invertColorScheme, + inverted, + maxWidth = chipMaxWidth, + compact, + gap = 1, + paddingX = compact ? 1.5 : 2, + paddingY = compact ? 0.5 : 1, + alignItems = 'center', + justifyContent, + padding, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + numberOfLines = 1, + testID, + contentStyle, + borderRadius = 700, + background = 'bgSecondary', + style, + styles, + onPress, + color = 'fg', + font = compact ? 'label1' : 'headline', + ...props + } = mergedProps; + const WrapperComponent = (invertColorScheme ?? inverted) ? InvertedThemeProvider : Fragment; + const containerProps = { + testID, + background, + borderRadius, + ref, + alignSelf, + style: [style, styles?.root], + }; - const content = ( - - {start} - {typeof children === 'string' ? ( - - {children} - - ) : children ? ( - - {children} - - ) : null} - {end} - - ); + const content = ( + + {start} + {typeof children === 'string' ? ( + + {children} + + ) : children ? ( + + {children} + + ) : null} + {end} + + ); - return ( - - {onPress ? ( - - {content} - - ) : ( - - {content} - - )} - - ); - }), -); + return ( + + {onPress ? ( + + {content} + + ) : ( + + {content} + + )} + + ); +}); diff --git a/packages/mobile/src/chips/InputChip.tsx b/packages/mobile/src/chips/InputChip.tsx index 821a75546a..ed3cb850bc 100644 --- a/packages/mobile/src/chips/InputChip.tsx +++ b/packages/mobile/src/chips/InputChip.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import { useComponentConfig } from '../hooks/useComponentConfig'; @@ -8,7 +8,12 @@ import type { InputChipProps } from './ChipProps'; import { MediaChip } from './MediaChip'; export const InputChip = memo( - forwardRef((_props: InputChipProps, ref: React.ForwardedRef) => { + ({ + ref, + ..._props + }: InputChipProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('InputChip', _props); const { value, @@ -37,5 +42,5 @@ export const InputChip = memo( {children} ); - }), + }, ); diff --git a/packages/mobile/src/chips/MediaChip.tsx b/packages/mobile/src/chips/MediaChip.tsx index f5014cb27c..ff6b753cef 100644 --- a/packages/mobile/src/chips/MediaChip.tsx +++ b/packages/mobile/src/chips/MediaChip.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { View } from 'react-native'; import { getMediaChipSpacingProps } from '@coinbase/cds-common/chips/getMediaChipSpacingProps'; @@ -10,56 +10,59 @@ import type { ChipBaseProps, ChipProps } from './ChipProps'; export type MediaChipBaseProps = ChipBaseProps; export type MediaChipProps = MediaChipBaseProps & ChipProps; -export const MediaChip = memo( - forwardRef(function MediaChip(_props: MediaChipProps, ref: React.ForwardedRef) { - const mergedProps = useComponentConfig('MediaChip', _props); - const { - start, - children, - end, - compact, - padding, - paddingX, - paddingY, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - ...props - } = mergedProps; - const spacingProps = useMemo(() => { - const defaults = getMediaChipSpacingProps({ - compact: !!compact, - start: !!start, - end: !!end, - children: !!children, - }); - return { - padding: padding ?? defaults.padding, - paddingX: paddingX ?? defaults.paddingX, - paddingY: paddingY ?? defaults.paddingY, - paddingTop: paddingTop ?? defaults.paddingTop, - paddingBottom: paddingBottom ?? defaults.paddingBottom, - paddingStart: paddingStart ?? defaults.paddingStart, - paddingEnd: paddingEnd ?? defaults.paddingEnd, - }; - }, [ - compact, - start, - end, - children, - padding, - paddingX, - paddingY, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - ]); - return ( - - {children} - - ); - }), -); +export const MediaChip = memo(function MediaChip({ + ref, + ..._props +}: MediaChipProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('MediaChip', _props); + const { + start, + children, + end, + compact, + padding, + paddingX, + paddingY, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + ...props + } = mergedProps; + const spacingProps = useMemo(() => { + const defaults = getMediaChipSpacingProps({ + compact: !!compact, + start: !!start, + end: !!end, + children: !!children, + }); + return { + padding: padding ?? defaults.padding, + paddingX: paddingX ?? defaults.paddingX, + paddingY: paddingY ?? defaults.paddingY, + paddingTop: paddingTop ?? defaults.paddingTop, + paddingBottom: paddingBottom ?? defaults.paddingBottom, + paddingStart: paddingStart ?? defaults.paddingStart, + paddingEnd: paddingEnd ?? defaults.paddingEnd, + }; + }, [ + compact, + start, + end, + children, + padding, + paddingX, + paddingY, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + ]); + return ( + + {children} + + ); +}); diff --git a/packages/mobile/src/chips/SelectChip.tsx b/packages/mobile/src/chips/SelectChip.tsx index 61137df28d..eb96687450 100644 --- a/packages/mobile/src/chips/SelectChip.tsx +++ b/packages/mobile/src/chips/SelectChip.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import type { GestureResponderEvent, View } from 'react-native'; import { animateCaretInConfig, animateCaretOutConfig } from '@coinbase/cds-common/animation/select'; import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; @@ -38,132 +38,132 @@ export type SelectChipProps = Pick< * @see {@link @coinbase/cds-mobile/alpha/select-chip/SelectChip} */ export const SelectChip = memo( - forwardRef( - ( - { - children, - value: defaultValue, - valueLabel, - placeholder, - disabled, - accessibilityLabel, - onPress, - end, - onChange, - onBlur, - testID = 'select-chip', - // tray props - preventDismissGestures, - hideHandleBar, - disableCapturePanGestureToDismiss, - verticalDrawerPercentageOfView, - handleBarAccessibilityLabel, - active, - ...props - }: SelectChipProps, - ref: React.ForwardedRef, - ) => { - const [isSelectTrayOpen, setIsSelectTrayOpen] = useState(false); - const { animateRotateIn, animateRotateOut, rotateAnimationStyles } = useRotateAnimation( - animateCaretInConfig, - animateCaretOutConfig, - 180, - ); - const { setA11yFocus, announceForA11y } = useA11y(); + ({ + ref, + children, + value: defaultValue, + valueLabel, + placeholder, + disabled, + accessibilityLabel, + onPress, + end, + onChange, + onBlur, + testID = 'select-chip', - const trayRef = useRef(null); - const internalRef = useRef(null); - const refs = useMergeRefs(ref, internalRef); + // tray props + preventDismissGestures, - const handleCloseTray = useCallback(() => { - trayRef.current?.handleClose(); - animateRotateOut.start(); - }, [animateRotateOut]); + hideHandleBar, + disableCapturePanGestureToDismiss, + verticalDrawerPercentageOfView, + handleBarAccessibilityLabel, + active, + ...props + }: SelectChipProps & { + ref?: React.Ref; + }) => { + const [isSelectTrayOpen, setIsSelectTrayOpen] = useState(false); + const { animateRotateIn, animateRotateOut, rotateAnimationStyles } = useRotateAnimation( + animateCaretInConfig, + animateCaretOutConfig, + 180, + ); + const { setA11yFocus, announceForA11y } = useA11y(); - const handleBlurTray = useCallback(() => { - handleCloseTray(); - onBlur?.(); - }, [handleCloseTray, onBlur]); + const trayRef = useRef(null); + const internalRef = useRef(null); + const refs = useMergeRefs(ref, internalRef); - const context = useSelect({ - value: defaultValue, - onChange, - handleClose: handleCloseTray, - }); - const { value } = context; + const handleCloseTray = useCallback(() => { + trayRef.current?.handleClose(); + animateRotateOut.start(); + }, [animateRotateOut]); - const handleA11y = useCallback(() => { - // bring a11y focus back to the trigger - setA11yFocus(internalRef); - // announce select value to screen reader - announceForA11y(`${value} selected`); - }, [value, announceForA11y, setA11yFocus]); + const handleBlurTray = useCallback(() => { + handleCloseTray(); + onBlur?.(); + }, [handleCloseTray, onBlur]); - useEffect(() => { - handleA11y(); - }, [handleA11y, value]); + const context = useSelect({ + value: defaultValue, + onChange, + handleClose: handleCloseTray, + }); + const { value } = context; - const handleChipPress = useCallback( - (event: GestureResponderEvent) => { - onPress?.(event); - setIsSelectTrayOpen(true); - animateRotateIn.start(); - }, - [animateRotateIn, onPress], - ); + const handleA11y = useCallback(() => { + // bring a11y focus back to the trigger + setA11yFocus(internalRef); + // announce select value to screen reader + announceForA11y(`${value} selected`); + }, [value, announceForA11y, setA11yFocus]); - const onCloseComplete = useCallback(() => { - setIsSelectTrayOpen(false); - // bring a11y focus back to the trigger - setA11yFocus(internalRef); - // announce select value to screen reader - announceForA11y(`${value} selected`); - }, [announceForA11y, setA11yFocus, value]); + useEffect(() => { + handleA11y(); + }, [handleA11y, value]); - return ( - - - ) - } - inverted={active} - onPress={handleChipPress} - testID={testID} - {...props} + const handleChipPress = useCallback( + (event: GestureResponderEvent) => { + onPress?.(event); + setIsSelectTrayOpen(true); + animateRotateIn.start(); + }, + [animateRotateIn, onPress], + ); + + const onCloseComplete = useCallback(() => { + setIsSelectTrayOpen(false); + // bring a11y focus back to the trigger + setA11yFocus(internalRef); + // announce select value to screen reader + announceForA11y(`${value} selected`); + }, [announceForA11y, setA11yFocus, value]); + + return ( + + + ) + } + inverted={active} + onPress={handleChipPress} + testID={testID} + {...props} + > + {valueLabel ?? value ?? placeholder} + + {isSelectTrayOpen && ( + - {valueLabel ?? value ?? placeholder} - - {isSelectTrayOpen && ( - - {children} - - )} - - ); - }, - ), + {children} + + )} + + ); + }, ); diff --git a/packages/mobile/src/chips/TabbedChips.tsx b/packages/mobile/src/chips/TabbedChips.tsx index d9cc6156e7..c2f4b98d54 100644 --- a/packages/mobile/src/chips/TabbedChips.tsx +++ b/packages/mobile/src/chips/TabbedChips.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { ScrollView } from 'react-native'; import type { View } from 'react-native'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; @@ -46,71 +46,69 @@ type TabbedChipsFC = ( props: TabbedChipsProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; -const TabbedChipsComponent = memo( - forwardRef(function TabbedChips( - _props: TabbedChipsProps, - ref: React.ForwardedRef, - ) { - const mergedProps = useComponentConfig('TabbedChips', _props); - const { - tabs, - value = tabs[0].id, - testID = 'tabbed-chips', - onChange, - Component = TabComponent, - ...props - } = mergedProps; - const activeTab = useMemo(() => tabs.find((tab) => tab.id === value), [tabs, value]); - const [scrollTarget, setScrollTarget] = useState(null); - const handleChange = useCallback( - (tabValue: TabValue | null) => { - if (tabValue) onChange?.(tabValue.id); - }, - [onChange], - ); +const TabbedChipsComponent = memo(function TabbedChips({ + ref, + ..._props +}: TabbedChipsProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('TabbedChips', _props); + const { + tabs, + value = tabs[0].id, + testID = 'tabbed-chips', + onChange, + Component = TabComponent, + ...props + } = mergedProps; + const activeTab = useMemo(() => tabs.find((tab) => tab.id === value), [tabs, value]); + const [scrollTarget, setScrollTarget] = useState(null); + const handleChange = useCallback( + (tabValue: TabValue | null) => { + if (tabValue) onChange?.(tabValue.id); + }, + [onChange], + ); - const { - scrollRef, - isScrollContentOverflowing, - isScrollContentOffscreenRight, - handleScroll, - handleScrollContainerLayout, - handleScrollContentSizeChange, - } = useHorizontalScrollToTarget({ activeTarget: scrollTarget }); + const { + scrollRef, + isScrollContentOverflowing, + isScrollContentOffscreenRight, + handleScroll, + handleScrollContainerLayout, + handleScrollContentSizeChange, + } = useHorizontalScrollToTarget({ activeTarget: scrollTarget }); - return ( - + - - - - {isScrollContentOverflowing && isScrollContentOffscreenRight ? : null} - - ); - }), -); + + + {isScrollContentOverflowing && isScrollContentOffscreenRight ? : null} + + ); +}); TabbedChipsComponent.displayName = 'TabbedChips'; diff --git a/packages/mobile/src/coachmark/Coachmark.tsx b/packages/mobile/src/coachmark/Coachmark.tsx index e937c2f5f1..06ba5d8816 100644 --- a/packages/mobile/src/coachmark/Coachmark.tsx +++ b/packages/mobile/src/coachmark/Coachmark.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import { useWindowDimensions } from 'react-native'; import type { DimensionValue, View } from 'react-native'; import { type SharedProps } from '@coinbase/cds-common'; @@ -49,7 +49,12 @@ export type CoachmarkBaseProps = SharedProps & export type CoachmarkProps = CoachmarkBaseProps & BoxProps; export const Coachmark = memo( - forwardRef((_props: CoachmarkProps, ref: React.ForwardedRef) => { + ({ + ref, + ..._props + }: CoachmarkProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Coachmark', _props); const { title, @@ -110,5 +115,5 @@ export const Coachmark = memo( ); - }), + }, ); diff --git a/packages/mobile/src/collapsible/Collapsible.tsx b/packages/mobile/src/collapsible/Collapsible.tsx index 49bdc9f559..eba1ed6e6f 100644 --- a/packages/mobile/src/collapsible/Collapsible.tsx +++ b/packages/mobile/src/collapsible/Collapsible.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useEffect, useMemo, useRef } from 'react'; +import React, { memo, useEffect, useMemo, useRef } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import type { ScrollViewProps } from 'react-native'; import Animated, { @@ -56,7 +56,12 @@ export type CollapsibleBaseProps = SharedProps & export type CollapsibleProps = CollapsibleBaseProps; export const Collapsible = memo( - forwardRef((_props: CollapsibleProps, forwardedRef: React.ForwardedRef) => { + ({ + ref: forwardedRef, + ..._props + }: CollapsibleProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Collapsible', _props); const { children, @@ -200,7 +205,7 @@ export const Collapsible = memo( ); - }), + }, ); const styles = StyleSheet.create({ diff --git a/packages/mobile/src/controls/Checkbox.tsx b/packages/mobile/src/controls/Checkbox.tsx index 188835f79a..bba78b4006 100644 --- a/packages/mobile/src/controls/Checkbox.tsx +++ b/packages/mobile/src/controls/Checkbox.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated } from 'react-native'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common'; @@ -103,10 +103,12 @@ const CheckboxIcon = memo( }, ); -const CheckboxWithRef = forwardRef(function Checkbox( - _props: CheckboxProps, - ref: React.ForwardedRef, -) { +const CheckboxWithRef = function Checkbox({ + ref, + ..._props +}: CheckboxProps & { + ref?: React.Ref; +}) { const mergedProps = useComponentConfig('Checkbox', _props); const { children, @@ -137,11 +139,8 @@ const CheckboxWithRef = forwardRef(function Checkbox ); - // Make forwardRef result function stay generic function type -}) as ( - props: CheckboxProps & { ref?: React.Ref }, -) => React.ReactElement; +}; -// Make memoized function stay generic function type +// Preserve generic call signature through React.memo export const Checkbox = memo(CheckboxWithRef) as typeof CheckboxWithRef & React.MemoExoticComponent; diff --git a/packages/mobile/src/controls/CheckboxCell.tsx b/packages/mobile/src/controls/CheckboxCell.tsx index 5418510080..4cafcc6785 100644 --- a/packages/mobile/src/controls/CheckboxCell.tsx +++ b/packages/mobile/src/controls/CheckboxCell.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { Animated, type GestureResponderEvent, @@ -47,10 +47,12 @@ export type CheckboxCellProps = }; }; -const CheckboxCellWithRef = forwardRef(function CheckboxCell( - _props: CheckboxCellProps, - ref: React.ForwardedRef, -) { +const CheckboxCellWithRef = function CheckboxCell({ + ref, + ..._props +}: CheckboxCellProps & { + ref?: React.Ref; +}) { const mergedProps = useComponentConfig('CheckboxCell', _props); const { title, @@ -252,9 +254,7 @@ const CheckboxCellWithRef = forwardRef(function CheckboxCell} ); -}) as ( - props: CheckboxCellProps & { ref?: React.ForwardedRef }, -) => React.ReactElement; +}; export const CheckboxCell = memo(CheckboxCellWithRef) as typeof CheckboxCellWithRef & React.MemoExoticComponent; diff --git a/packages/mobile/src/controls/CheckboxGroup.tsx b/packages/mobile/src/controls/CheckboxGroup.tsx index 5142c91c2a..d7643abc3e 100644 --- a/packages/mobile/src/controls/CheckboxGroup.tsx +++ b/packages/mobile/src/controls/CheckboxGroup.tsx @@ -1,4 +1,4 @@ -import React, { Children, forwardRef, isValidElement, memo, useMemo } from 'react'; +import React, { Children, isValidElement, memo, useMemo } from 'react'; import type { View, ViewProps } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; import { isDevelopment } from '@coinbase/cds-utils'; @@ -21,18 +21,18 @@ type CheckboxGroupBaseProps = Omit = CheckboxGroupBaseProps; // Follows behavior describe in https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html -const CheckboxGroupWithRef = forwardRef(function CheckboxGroupWithRef( - { - children, - label, - accessibilityLabel, - onChange, - selectedValues, - testID, - ...restProps - }: CheckboxGroupProps, - ref: React.ForwardedRef, -) { +const CheckboxGroupWithRef = function CheckboxGroupWithRef({ + ref, + children, + label, + accessibilityLabel, + onChange, + selectedValues, + testID, + ...restProps +}: CheckboxGroupProps & { + ref?: React.Ref; +}) { if (isDevelopment()) { console.warn( 'CheckboxGroup is deprecated. Use ControlGroup with accessibilityRole="combobox" instead.', @@ -89,12 +89,9 @@ const CheckboxGroupWithRef = forwardRef(function CheckboxGroupWithRef ); - // Make forwardRef result function stay generic function type -}) as ( - props: CheckboxGroupProps & { ref?: React.Ref }, -) => React.ReactElement; +}; -// Make memoized function stay generic function type +// Preserve generic call signature through React.memo /** * @deprecated CheckboxGroup is deprecated. Use ControlGroup with accessibilityRole="combobox" instead. This will be removed in a future major release. * @deprecationExpectedRemoval v8 diff --git a/packages/mobile/src/controls/Control.tsx b/packages/mobile/src/controls/Control.tsx index eab70022d8..9b05929e63 100644 --- a/packages/mobile/src/controls/Control.tsx +++ b/packages/mobile/src/controls/Control.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { I18nManager, Keyboard, Pressable, View } from 'react-native'; import type { AccessibilityActionEvent, @@ -98,10 +98,12 @@ export type ControlProps = Omit< shouldUseSwitchTransition?: boolean; }; -const ControlWithRef = forwardRef(function ControlWithRef( - _props: ControlProps, - ref: React.ForwardedRef, -) { +const ControlWithRef = function ControlWithRef({ + ref, + ..._props +}: ControlProps & { + ref?: React.Ref; +}) { const mergedProps = useComponentConfig('Control', _props); const { testID, @@ -311,11 +313,8 @@ const ControlWithRef = forwardRef(function ControlWithRef ); - // Make forwardRef result function stay generic function type -}) as ( - props: ControlProps & { ref?: React.Ref }, -) => React.ReactElement; +}; -// Make memoized function stay generic function type +// Preserve generic call signature through React.memo export const Control = memo(ControlWithRef) as typeof ControlWithRef & React.MemoExoticComponent; diff --git a/packages/mobile/src/controls/ControlGroup.tsx b/packages/mobile/src/controls/ControlGroup.tsx index ff759827ec..222d02124f 100644 --- a/packages/mobile/src/controls/ControlGroup.tsx +++ b/packages/mobile/src/controls/ControlGroup.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; import { isDevelopment } from '@coinbase/cds-utils'; @@ -34,10 +34,15 @@ export type ControlGroupProps< > = ControlGroupBaseProps & Omit; -const ControlGroupWithRef = forwardRef(function ControlGroup< +const ControlGroupWithRef = function ControlGroup< ControlValue extends string, ControlComponentProps extends { value?: ControlValue }, ->(_props: ControlGroupProps, ref: React.ForwardedRef) { +>({ + ref, + ..._props +}: ControlGroupProps & { + ref?: React.Ref; +}) { const mergedProps = useComponentConfig('ControlGroup', _props); const { ControlComponent, @@ -86,12 +91,7 @@ const ControlGroupWithRef = forwardRef(function ControlGroup< })} ); -}) as unknown as < - ControlValue extends string, - ControlComponentProps extends { value?: ControlValue }, ->( - props: ControlGroupProps & { ref?: React.Ref }, -) => React.ReactElement; +}; export const ControlGroup = memo(ControlGroupWithRef) as typeof ControlGroupWithRef & React.MemoExoticComponent; diff --git a/packages/mobile/src/controls/InputIconButton.tsx b/packages/mobile/src/controls/InputIconButton.tsx index 95d93ac754..2e597a9530 100644 --- a/packages/mobile/src/controls/InputIconButton.tsx +++ b/packages/mobile/src/controls/InputIconButton.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useContext } from 'react'; +import React, { memo, useContext } from 'react'; import type { View } from 'react-native'; import type { IconButtonVariant, InputVariant } from '@coinbase/cds-common/types'; @@ -24,32 +24,30 @@ export type InputIconButtonProps = IconButtonProps & { disableInheritFocusStyle?: boolean; }; -export const InputIconButton = memo( - forwardRef(function InputIconButton( - { - disableInheritFocusStyle = false, - testID, - variant = 'primary', - accessibilityLabel, - accessibilityHint, - ...props - }, - ref, - ) { - const contextVariant = useContext(TextInputFocusVariantContext); - const transformedVariant = contextVariant ? variantTransformMap[contextVariant] : variant; +export const InputIconButton = memo(function InputIconButton({ + ref, + disableInheritFocusStyle = false, + testID, + variant = 'primary', + accessibilityLabel, + accessibilityHint, + ...props +}: InputIconButtonProps & { + ref?: React.Ref; +}) { + const contextVariant = useContext(TextInputFocusVariantContext); + const transformedVariant = contextVariant ? variantTransformMap[contextVariant] : variant; - return ( - - - - ); - }), -); + return ( + + + + ); +}); diff --git a/packages/mobile/src/controls/NativeInput.tsx b/packages/mobile/src/controls/NativeInput.tsx index a945234498..147bcc4124 100644 --- a/packages/mobile/src/controls/NativeInput.tsx +++ b/packages/mobile/src/controls/NativeInput.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { TextInput } from 'react-native'; import type { StyleProp, TextInputProps, TextStyle, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -49,95 +49,86 @@ export type NativeInputProps = { Omit; export const NativeInput = memo( - forwardRef( - ( - { - containerSpacing, - testID = '', - align = 'start', - disabled, - textAlign, - font = 'body', - accessibilityLabel, - compact, - selectionColor = 'fgPrimary', - style, - ...editableInputAddonProps - }: NativeInputProps, - ref: React.ForwardedRef, - ) => { - const theme = useTheme(); - const textAlignInputTransformed = useTextAlign(align).textAlign; + ({ + ref, + containerSpacing, + testID = '', + align = 'start', + disabled, + textAlign, + font = 'body', + accessibilityLabel, + compact, + selectionColor = 'fgPrimary', + style, + ...editableInputAddonProps + }: NativeInputProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + const textAlignInputTransformed = useTextAlign(align).textAlign; - const inputTextStyle: TextStyle = useMemo( - () => ({ - fontSize: theme.fontSize[font], - fontFamily: theme.fontFamily[font], - minHeight: theme.lineHeight[font], - fontWeight: theme.fontWeight[font], - padding: 0, - margin: 0, - color: theme.color.fg, - }), - [ - theme.fontSize, - theme.fontFamily, - theme.lineHeight, - theme.fontWeight, - theme.color.fg, - font, - ], - ); + const inputTextStyle: TextStyle = useMemo( + () => ({ + fontSize: theme.fontSize[font], + fontFamily: theme.fontFamily[font], + minHeight: theme.lineHeight[font], + fontWeight: theme.fontWeight[font], + padding: 0, + margin: 0, + color: theme.color.fg, + }), + [theme.fontSize, theme.fontFamily, theme.lineHeight, theme.fontWeight, theme.color.fg, font], + ); - const containerStyle: ViewStyle = useMemo(() => { - return { - flex: 2, - minWidth: 0, - padding: theme.space[compact ? 1 : 2], - ...containerSpacing, - ...(!disabled && - editableInputAddonProps.readOnly && { - backgroundColor: theme.color.bgSecondary, - }), - }; - }, [ - containerSpacing, - theme.space, - theme.color, - compact, - editableInputAddonProps.readOnly, - disabled, - ]); + const containerStyle: ViewStyle = useMemo(() => { + return { + flex: 2, + minWidth: 0, + padding: theme.space[compact ? 1 : 2], + ...containerSpacing, + ...(!disabled && + editableInputAddonProps.readOnly && { + backgroundColor: theme.color.bgSecondary, + }), + }; + }, [ + containerSpacing, + theme.space, + theme.color, + compact, + editableInputAddonProps.readOnly, + disabled, + ]); - const inputRootStyles = useMemo(() => { - return [ - inputTextStyle, - containerStyle, - /** - * To workaround a known RN bug (link below) where long text does not ellipsis correctly in TextInput - * @link https://github.com/facebook/react-native/issues/29068 - */ - { textAlign: textAlign === 'unset' ? undefined : textAlignInputTransformed }, - style, - ]; - }, [inputTextStyle, containerStyle, textAlign, textAlignInputTransformed, style]); + const inputRootStyles = useMemo(() => { + return [ + inputTextStyle, + containerStyle, + /** + * To workaround a known RN bug (link below) where long text does not ellipsis correctly in TextInput + * @link https://github.com/facebook/react-native/issues/29068 + */ + { textAlign: textAlign === 'unset' ? undefined : textAlignInputTransformed }, + style, + ]; + }, [inputTextStyle, containerStyle, textAlign, textAlignInputTransformed, style]); - return ( - - ); - }, - ), + return ( + + ); + }, ); diff --git a/packages/mobile/src/controls/Radio.tsx b/packages/mobile/src/controls/Radio.tsx index 4846edc110..9c14a9112e 100644 --- a/packages/mobile/src/controls/Radio.tsx +++ b/packages/mobile/src/controls/Radio.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import { Animated, type ColorValue, StyleSheet, type View } from 'react-native'; import { Circle, Svg } from 'react-native-svg'; import type { ThemeVars } from '@coinbase/cds-common'; @@ -109,10 +109,12 @@ const RadioIcon: React.FC> = ({ ); }; -const RadioWithRef = forwardRef(function Radio( - _props: RadioProps, - ref: React.ForwardedRef, -) { +const RadioWithRef = function Radio({ + ref, + ..._props +}: RadioProps & { + ref?: React.Ref; +}) { const mergedProps = useComponentConfig('Radio', _props); const { children, accessibilityHint, accessibilityLabel, ...props } = mergedProps; const accessibilityLabelValue = @@ -132,11 +134,8 @@ const RadioWithRef = forwardRef(function Radio( {RadioIcon} ); - // Make forwardRef result function stay generic function type -}) as ( - props: RadioProps & { ref?: React.Ref }, -) => React.ReactElement; +}; -// Make memoized function stay generic function type +// Preserve generic call signature through React.memo export const Radio = memo(RadioWithRef) as typeof RadioWithRef & React.MemoExoticComponent; diff --git a/packages/mobile/src/controls/RadioCell.tsx b/packages/mobile/src/controls/RadioCell.tsx index ee66a3a3fe..ed23cbf7e5 100644 --- a/packages/mobile/src/controls/RadioCell.tsx +++ b/packages/mobile/src/controls/RadioCell.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { Animated, type GestureResponderEvent, @@ -46,10 +46,12 @@ export type RadioCellProps = RadioCellBaseProps( - _props: RadioCellProps, - ref: React.ForwardedRef, -) { +const RadioCellWithRef = function RadioCell({ + ref, + ..._props +}: RadioCellProps & { + ref?: React.Ref; +}) { const mergedProps = useComponentConfig('RadioCell', _props); const { title, @@ -248,9 +250,7 @@ const RadioCellWithRef = forwardRef(function RadioCell} ); -}) as ( - props: RadioCellProps & { ref?: React.ForwardedRef }, -) => React.ReactElement; +}; export const RadioCell = memo(RadioCellWithRef) as typeof RadioCellWithRef & React.MemoExoticComponent; diff --git a/packages/mobile/src/controls/RadioGroup.tsx b/packages/mobile/src/controls/RadioGroup.tsx index 80823a8132..9418f5a2ea 100644 --- a/packages/mobile/src/controls/RadioGroup.tsx +++ b/packages/mobile/src/controls/RadioGroup.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { AccessibilityProps, View } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; import { entries } from '@coinbase/cds-utils'; @@ -39,21 +39,21 @@ type RadioGroupBaseProps = Omit< type RadioGroupProps = RadioGroupBaseProps; -const RadioGroupWithRef = forwardRef(function RadioGroup( - { - label, - value, - onChange, - options, - testID, - controlColor = 'bgPrimary', - accessibilityLabel, - accessibilityHint, - radioAccessibilityLabel, - ...props - }: RadioGroupProps, - ref: React.ForwardedRef, -) { +const RadioGroupWithRef = function RadioGroup({ + ref, + label, + value, + onChange, + options, + testID, + controlColor = 'bgPrimary', + accessibilityLabel, + accessibilityHint, + radioAccessibilityLabel, + ...props +}: RadioGroupProps & { + ref?: React.Ref; +}) { if (isDevelopment()) { console.warn( 'RadioGroup is deprecated. Use ControlGroup with accessibilityRole="radiogroup" instead.', @@ -116,12 +116,9 @@ const RadioGroupWithRef = forwardRef(function RadioGroup ); - // Make forwardRef result function stay generic function type -}) as ( - props: RadioGroupProps & { ref?: React.Ref }, -) => React.ReactElement; +}; -// Make memoized function stay generic function type +// Preserve generic call signature through React.memo /** * @deprecated RadioGroup is deprecated. Use ControlGroup with accessibilityRole="radiogroup" instead. This will be removed in a future major release. * @deprecationExpectedRemoval v8 diff --git a/packages/mobile/src/controls/SearchInput.tsx b/packages/mobile/src/controls/SearchInput.tsx index 4c91475237..0d9434ef78 100644 --- a/packages/mobile/src/controls/SearchInput.tsx +++ b/packages/mobile/src/controls/SearchInput.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; import type { ForwardedRef } from 'react'; import type { BlurEvent, @@ -91,7 +91,12 @@ export type SearchInputProps = SearchInputBaseProps & }; export const SearchInput = memo( - forwardRef((_props: SearchInputProps, ref: ForwardedRef) => { + ({ + ref, + ..._props + }: SearchInputProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('SearchInput', _props); const { value, @@ -215,5 +220,5 @@ export const SearchInput = memo( {...props} /> ); - }), + }, ); diff --git a/packages/mobile/src/controls/Select.tsx b/packages/mobile/src/controls/Select.tsx index 5bba5dbcd8..3426336122 100644 --- a/packages/mobile/src/controls/Select.tsx +++ b/packages/mobile/src/controls/Select.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { TouchableWithoutFeedback } from 'react-native'; import type { ForwardedRef } from 'react'; import { animateCaretInConfig, animateCaretOutConfig } from '@coinbase/cds-common/animation/select'; @@ -69,200 +69,195 @@ export type SelectProps = SelectBaseProps; * @deprecationExpectedRemoval v9 */ export const Select = memo( - forwardRef( - ( - { - children, - placeholder, - label, - helperText, - variant = 'foregroundMuted', - value: defaultValue, - valueLabel, - disabled = false, - testID, - width = '100%', - accessibilityLabel, - accessibilityHint, - compact, - onPress, - startNode, - onChange, - labelVariant = 'outside', - }: SelectProps, - ref: ForwardedRef, - ) => { - const { rotateAnimation, animateRotateIn, animateRotateOut, rotateAnimationStyles } = - useRotateAnimation(animateCaretInConfig, animateCaretOutConfig, 180); - const [isSelectTrayOpen, toggleSelectTray] = useState(false); - const toggleSelectTrayOff = useCallback(() => toggleSelectTray(false), [toggleSelectTray]); - const toggleSelectTrayOn = useCallback(() => toggleSelectTray(true), [toggleSelectTray]); - const focusedVariant = useInputVariant(!!isSelectTrayOpen, variant); - const sanitizedValue = defaultValue === '' ? undefined : defaultValue; - const internalRef = useRef(null); - const refs = useMergeRefs(ref, internalRef); - const context = useSelect({ value: sanitizedValue, onChange }); - const { value } = context; - const { setA11yFocus, announceForA11y } = useA11y(); - const getSpacingStart = compact ? 1 : 2; - const minTriggerHeight = compact - ? selectTriggerCompactMinHeight - : labelVariant === 'inside' && Boolean(label) - ? selectTriggerInsideLabelMinHeight - : selectTriggerMinHeight; + ({ + ref, + children, + placeholder, + label, + helperText, + variant = 'foregroundMuted', + value: defaultValue, + valueLabel, + disabled = false, + testID, + width = '100%', + accessibilityLabel, + accessibilityHint, + compact, + onPress, + startNode, + onChange, + labelVariant = 'outside', + }: SelectProps & { + ref?: React.Ref; + }) => { + const { rotateAnimation, animateRotateIn, animateRotateOut, rotateAnimationStyles } = + useRotateAnimation(animateCaretInConfig, animateCaretOutConfig, 180); + const [isSelectTrayOpen, toggleSelectTray] = useState(false); + const toggleSelectTrayOff = useCallback(() => toggleSelectTray(false), [toggleSelectTray]); + const toggleSelectTrayOn = useCallback(() => toggleSelectTray(true), [toggleSelectTray]); + const focusedVariant = useInputVariant(!!isSelectTrayOpen, variant); + const sanitizedValue = defaultValue === '' ? undefined : defaultValue; + const internalRef = useRef(null); + const refs = useMergeRefs(ref, internalRef); + const context = useSelect({ value: sanitizedValue, onChange }); + const { value } = context; + const { setA11yFocus, announceForA11y } = useA11y(); + const getSpacingStart = compact ? 1 : 2; + const minTriggerHeight = compact + ? selectTriggerCompactMinHeight + : labelVariant === 'inside' && Boolean(label) + ? selectTriggerInsideLabelMinHeight + : selectTriggerMinHeight; - const { borderFocusedStyle, borderUnfocusedStyle } = useInputBorderStyle( - !!isSelectTrayOpen, - variant, - focusedVariant, - ); + const { borderFocusedStyle, borderUnfocusedStyle } = useInputBorderStyle( + !!isSelectTrayOpen, + variant, + focusedVariant, + ); - const labelTextColor = 'fg'; + const labelTextColor = 'fg'; - const handleA11y = useCallback(() => { - // bring a11y focus back to the trigger - setA11yFocus(internalRef); - // announce select value to screen reader - announceForA11y(`${value} selected`); - }, [value, announceForA11y, setA11yFocus]); + const handleA11y = useCallback(() => { + // bring a11y focus back to the trigger + setA11yFocus(internalRef); + // announce select value to screen reader + announceForA11y(`${value} selected`); + }, [value, announceForA11y, setA11yFocus]); - useEffect(() => { - if (children) { - rotateAnimation.flattenOffset(); - animateRotateIn.start(); - return; + useEffect(() => { + if (children) { + rotateAnimation.flattenOffset(); + animateRotateIn.start(); + return; + } + animateRotateOut.start(({ finished }) => { + if (finished) { + // This needs to execute after exit animation to avoid interrupting announcement. + handleA11y(); } - animateRotateOut.start(({ finished }) => { - if (finished) { - // This needs to execute after exit animation to avoid interrupting announcement. - handleA11y(); - } - }); - toggleSelectTrayOff(); - }, [ - animateRotateIn, - animateRotateOut, - children, - rotateAnimation, - toggleSelectTrayOff, - handleA11y, - ]); + }); + toggleSelectTrayOff(); + }, [ + animateRotateIn, + animateRotateOut, + children, + rotateAnimation, + toggleSelectTrayOff, + handleA11y, + ]); - const handleOnSubjectPress = useCallback(() => { - onPress?.(); - toggleSelectTrayOn(); - }, [onPress, toggleSelectTrayOn]); + const handleOnSubjectPress = useCallback(() => { + onPress?.(); + toggleSelectTrayOn(); + }, [onPress, toggleSelectTrayOn]); - const accessibilityState = useMemo(() => ({ disabled: !!disabled }), [disabled]); + const accessibilityState = useMemo(() => ({ disabled: !!disabled }), [disabled]); - const defaultAccessibilityLabel = - (variant === 'negative' ? 'error, ' : '') + - (label ? `${label}, ` : '') + - ((value ?? placeholder) ? `${value ?? placeholder}, ` : '') + - (typeof helperText === 'string' ? helperText : ''); + const defaultAccessibilityLabel = + (variant === 'negative' ? 'error, ' : '') + + (label ? `${label}, ` : '') + + ((value ?? placeholder) ? `${value ?? placeholder}, ` : '') + + (typeof helperText === 'string' ? helperText : ''); - const inputNodePaddingY = labelVariant === 'inside' && Boolean(label) ? 0 : compact ? 1 : 2; - const inputNodePaddingStart = startNode ? 0 : getSpacingStart; + const inputNodePaddingY = labelVariant === 'inside' && Boolean(label) ? 0 : compact ? 1 : 2; + const inputNodePaddingStart = startNode ? 0 : getSpacingStart; - return ( - - - + + + - - - - } - focused={isSelectTrayOpen} - helperTextNode={ - Boolean(helperText) && - (typeof helperText === 'string' ? ( - - {helperText} - - ) : ( - helperText - )) - } - inputNode={ - + + + } + focused={isSelectTrayOpen} + helperTextNode={ + Boolean(helperText) && + (typeof helperText === 'string' ? ( + + {helperText} + + ) : ( + helperText + )) + } + inputNode={ + + - - {valueLabel ?? value ?? placeholder} - - - } - labelNode={ - !compact && - Boolean(label) && ( - - {label} - - ) - } - labelVariant={labelVariant} - startNode={ - <> - {compact && ( - - - {label} - - - )} - {!!startNode && {startNode}} - - } - variant={variant} - width={width} - /> - - {isSelectTrayOpen && children} - - - ); - }, - ), + {valueLabel ?? value ?? placeholder} + + + } + labelNode={ + !compact && + Boolean(label) && ( + + {label} + + ) + } + labelVariant={labelVariant} + startNode={ + <> + {compact && ( + + + {label} + + + )} + {!!startNode && {startNode}} + + } + variant={variant} + width={width} + /> + + {isSelectTrayOpen && children} + + + ); + }, ); diff --git a/packages/mobile/src/controls/Switch.tsx b/packages/mobile/src/controls/Switch.tsx index 5b8d0904ab..3d3340d616 100644 --- a/packages/mobile/src/controls/Switch.tsx +++ b/packages/mobile/src/controls/Switch.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { type StyleProp, StyleSheet, type View, type ViewStyle } from 'react-native'; import { useComponentConfig } from '../hooks/useComponentConfig'; @@ -115,10 +115,12 @@ const SwitchIcon = ({ ); }; -const SwitchWithRef = forwardRef(function SwitchWithRef( - _props: SwitchProps, - ref: React.ForwardedRef, -) { +const SwitchWithRef = function SwitchWithRef({ + ref, + ..._props +}: SwitchProps & { + ref?: React.Ref; +}) { const mergedProps = useComponentConfig('Switch', _props); const { children, style, styles, ...props } = mergedProps; const theme = useTheme(); @@ -152,7 +154,7 @@ const SwitchWithRef = forwardRef(function SwitchWithRef ); -}); +}; export const Switch = memo(SwitchWithRef); diff --git a/packages/mobile/src/controls/TextInput.tsx b/packages/mobile/src/controls/TextInput.tsx index 85aeab1f2c..7fce82fb31 100644 --- a/packages/mobile/src/controls/TextInput.tsx +++ b/packages/mobile/src/controls/TextInput.tsx @@ -1,6 +1,5 @@ import React, { cloneElement, - forwardRef, isValidElement, memo, useCallback, @@ -132,7 +131,12 @@ const variantColorMap: Record = { }; export const TextInput = memo( - forwardRef((_props: TextInputProps, ref: ForwardedRef) => { + ({ + ref, + ..._props + }: TextInputProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('TextInput', _props); const { label, @@ -384,5 +388,5 @@ export const TextInput = memo( width={width} /> ); - }), + }, ); diff --git a/packages/mobile/src/dates/Calendar.tsx b/packages/mobile/src/dates/Calendar.tsx index 20d104a97b..8f68024ee0 100644 --- a/packages/mobile/src/dates/Calendar.tsx +++ b/packages/mobile/src/dates/Calendar.tsx @@ -1,12 +1,4 @@ -import { - forwardRef, - memo, - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; +import { memo, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { type StyleProp, StyleSheet, @@ -54,23 +46,29 @@ export type CalendarPressableBaseProps = PressableBaseProps & { }; const CalendarPressable = memo( - forwardRef( - ({ background = 'transparent', borderRadius = 1000, children, ...props }, ref) => { - return ( - - {children} - - ); - }, - ), + ({ + ref, + background = 'transparent', + borderRadius = 1000, + children, + ...props + }: CalendarPressableBaseProps & { + ref?: React.Ref; + }) => { + return ( + + {children} + + ); + }, ); CalendarPressable.displayName = 'CalendarPressable'; @@ -110,112 +108,110 @@ const getDayAccessibilityLabel = (date: Date, locale = 'en-US') => })}`; const CalendarDay = memo( - forwardRef( - ( - { - date, - active, - disabled, - highlighted, - isToday, - isCurrentMonth, - onPress, - disabledError, - todayAccessibilityHint, - highlightedDateAccessibilityHint, - style, - }, - ref, - ) => { - const { locale } = useLocale(); - const handlePress = useCallback(() => onPress?.(date), [date, onPress]); - const accessibilityLabel = useMemo( - () => getDayAccessibilityLabel(date, locale), - [date, locale], - ); - const accessibilityState = useMemo( - () => ({ disabled: !!disabled, selected: !!active }), - [disabled, active], - ); + ({ + ref, + date, + active, + disabled, + highlighted, + isToday, + isCurrentMonth, + onPress, + disabledError, + todayAccessibilityHint, + highlightedDateAccessibilityHint, + style, + }: CalendarDayProps & { + ref?: React.Ref; + }) => { + const { locale } = useLocale(); + const handlePress = useCallback(() => onPress?.(date), [date, onPress]); + const accessibilityLabel = useMemo( + () => getDayAccessibilityLabel(date, locale), + [date, locale], + ); + const accessibilityState = useMemo( + () => ({ disabled: !!disabled, selected: !!active }), + [disabled, active], + ); - // Period between phrases gives screen readers a clear pause (e.g. "Today. Date unavailable"). - const accessibilityHint = useMemo(() => { - const hints = [ - isToday ? todayAccessibilityHint : undefined, - highlighted ? highlightedDateAccessibilityHint : undefined, - disabled ? disabledError : undefined, - ] - .filter(Boolean) - .join('. '); - return hints || undefined; - }, [ - disabled, - highlighted, - isToday, - todayAccessibilityHint, - highlightedDateAccessibilityHint, - disabledError, - ]); - - const isScreenReaderEnabled = useScreenReaderStatus(); - - // Expose disabled to the tooltip's accessibilityState so screen readers on both platforms - // announce the day button as disabled. We only set disabled when a screen reader is active: - // on some platforms a11y disabled is equivalent to the top-level disabled prop, so always - // setting it would block tooltip interactivity for users not using SRs. - const tooltipAccessibilityState = useMemo( - () => ({ disabled: isScreenReaderEnabled }), - [isScreenReaderEnabled], - ); + // Period between phrases gives screen readers a clear pause (e.g. "Today. Date unavailable"). + const accessibilityHint = useMemo(() => { + const hints = [ + isToday ? todayAccessibilityHint : undefined, + highlighted ? highlightedDateAccessibilityHint : undefined, + disabled ? disabledError : undefined, + ] + .filter(Boolean) + .join('. '); + return hints || undefined; + }, [ + disabled, + highlighted, + isToday, + todayAccessibilityHint, + highlightedDateAccessibilityHint, + disabledError, + ]); + + const isScreenReaderEnabled = useScreenReaderStatus(); + + // Expose disabled to the tooltip's accessibilityState so screen readers on both platforms + // announce the day button as disabled. We only set disabled when a screen reader is active: + // on some platforms a11y disabled is equivalent to the top-level disabled prop, so always + // setting it would block tooltip interactivity for users not using SRs. + const tooltipAccessibilityState = useMemo( + () => ({ disabled: isScreenReaderEnabled }), + [isScreenReaderEnabled], + ); - if (!isCurrentMonth) { - return ( - - ); - } + if (!isCurrentMonth) { + return ( + + ); + } + + const dayButton = ( + + + {date.getDate()} + + + ); - const dayButton = ( - - - {date.getDate()} - - + {dayButton} + ); + } - if (disabled) { - return ( - - {dayButton} - - ); - } - - return dayButton; - }, - ), + return dayButton; + }, ); CalendarDay.displayName = 'CalendarDay'; @@ -292,7 +288,12 @@ export type CalendarProps = CalendarBaseProps & }; export const Calendar = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: CalendarProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Calendar', _props); const { selectedDate, @@ -536,7 +537,7 @@ export const Calendar = memo( ); - }), + }, ); Calendar.displayName = 'Calendar'; diff --git a/packages/mobile/src/dates/DateInput.tsx b/packages/mobile/src/dates/DateInput.tsx index e736909458..d44a1c1191 100644 --- a/packages/mobile/src/dates/DateInput.tsx +++ b/packages/mobile/src/dates/DateInput.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; +import React, { memo, useCallback, useMemo, useRef } from 'react'; import { type BlurEvent, type NativeSyntheticEvent, @@ -28,7 +28,12 @@ export type DateInputProps = DateInputBaseProps & }; export const DateInput = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: DateInputProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('DateInput', _props); const { date, @@ -137,5 +142,5 @@ export const DateInput = memo( /> ); - }), + }, ); diff --git a/packages/mobile/src/dates/DatePicker.tsx b/packages/mobile/src/dates/DatePicker.tsx index b1ce7fc536..036d3416d8 100644 --- a/packages/mobile/src/dates/DatePicker.tsx +++ b/packages/mobile/src/dates/DatePicker.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { type NativeSyntheticEvent, type StyleProp, @@ -109,7 +109,12 @@ export type DatePickerProps = DatePickerBaseProps & }; export const DatePicker = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: DatePickerProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('DatePicker', _props); const { date, @@ -311,7 +316,7 @@ export const DatePicker = memo( )} ); - }), + }, ); DatePicker.displayName = 'DatePicker'; diff --git a/packages/mobile/src/examples/ExampleScreen.tsx b/packages/mobile/src/examples/ExampleScreen.tsx index 1abca8d9e6..392133c657 100644 --- a/packages/mobile/src/examples/ExampleScreen.tsx +++ b/packages/mobile/src/examples/ExampleScreen.tsx @@ -62,46 +62,50 @@ export const Example = ({ ); }; -export const ExampleScreen = React.forwardRef>( - ({ children, ...boxProps }, ref) => { - const theme = useTheme(); +export const ExampleScreen = ({ + ref, + children, + ...boxProps +}: React.PropsWithChildren & { + ref?: React.Ref; +}) => { + const theme = useTheme(); - // Use ref to track count - this avoids stale closure issues when multiple - // Example components mount simultaneously - const exampleCountRef = useRef(0); - const registerExample = useCallback(() => { - exampleCountRef.current += 1; - return exampleCountRef.current; - }, []); + // Use ref to track count - this avoids stale closure issues when multiple + // Example components mount simultaneously + const exampleCountRef = useRef(0); + const registerExample = useCallback(() => { + exampleCountRef.current += 1; + return exampleCountRef.current; + }, []); - const context = useMemo(() => ({ registerExample }), [registerExample]); - return ( - - ({ registerExample }), [registerExample]); + return ( + + + - - {children} - - - - ); - }, -); + {children} + + + + ); +}; ExampleScreen.displayName = 'ExampleScreen'; diff --git a/packages/mobile/src/layout/Box.tsx b/packages/mobile/src/layout/Box.tsx index 0a70083eb7..85645bd275 100644 --- a/packages/mobile/src/layout/Box.tsx +++ b/packages/mobile/src/layout/Box.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated, type StyleProp, View, type ViewProps, type ViewStyle } from 'react-native'; import type { PinningDirection, SharedProps } from '@coinbase/cds-common'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -134,23 +134,190 @@ const getBorderedStyles = ( }; export const Box = memo( - forwardRef( - ( - { - children, + ({ + ref, + children, + style, + animated, + testID, + pin, + bordered, + borderedTop, + borderedBottom, + borderedStart, + borderedEnd, + borderedHorizontal, + borderedVertical, + dangerouslySetBackground, + + // Begin style props + display, + + position, + overflow, + zIndex, + gap, + columnGap, + rowGap, + justifyContent, + alignContent, + alignItems, + alignSelf, + flexDirection, + flexWrap, + color, + background, + borderColor, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopWidth, + borderEndWidth, + borderBottomWidth, + borderStartWidth, + elevation, + borderWidth, + borderRadius, + font, + fontFamily = font, + fontSize = font, + fontWeight = font, + lineHeight = font, + textAlign, + textDecorationStyle, + textDecorationLine, + textTransform, + padding, + paddingX, + paddingY, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + margin, + marginX, + marginY, + marginTop, + marginBottom, + marginStart, + marginEnd, + userSelect, + width, + height, + minWidth, + minHeight, + maxWidth, + maxHeight, + aspectRatio, + top, + bottom, + left, + right, + transform, + flexBasis, + flexShrink, + flexGrow, + opacity, + ...props + }: BoxProps & { + ref?: React.Ref; + }) => { + const Component = animated ? Animated.View : View; + + const theme = useTheme(); + + const styles = useMemo( + () => [ + getBorderedStyles( + { + bordered, + borderedHorizontal, + borderedVertical, + borderedStart, + borderedEnd, + borderedTop, + borderedBottom, + }, + theme, + ), + getStyles( + { + display, + position, + overflow, + zIndex, + gap, + columnGap, + rowGap, + justifyContent, + alignContent, + alignItems, + alignSelf, + flexDirection, + flexWrap, + color, + background, + borderColor, + borderWidth, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopWidth, + borderEndWidth, + borderBottomWidth, + borderStartWidth, + elevation, + fontFamily, + fontSize, + fontWeight, + lineHeight, + textAlign, + textDecorationStyle, + textDecorationLine, + textTransform, + padding, + paddingX, + paddingY, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + margin, + marginX, + marginY, + marginTop, + marginBottom, + marginStart, + marginEnd, + userSelect, + width, + height, + minWidth, + minHeight, + maxWidth, + maxHeight, + aspectRatio, + top, + bottom, + left, + right, + transform, + flexBasis, + flexShrink, + flexGrow, + opacity, + }, + theme, + ), + elevation ? getElevationStyles(elevation, theme, background) : undefined, + pin && pinStyles[pin], + dangerouslySetBackground ? { backgroundColor: dangerouslySetBackground } : undefined, style, - animated, - testID, - pin, - bordered, - borderedTop, - borderedBottom, - borderedStart, - borderedEnd, - borderedHorizontal, - borderedVertical, - dangerouslySetBackground, - // Begin style props + ], + [ display, position, overflow, @@ -167,6 +334,8 @@ export const Box = memo( color, background, borderColor, + borderWidth, + borderRadius, borderTopLeftRadius, borderTopRightRadius, borderBottomLeftRadius, @@ -176,13 +345,10 @@ export const Box = memo( borderBottomWidth, borderStartWidth, elevation, - borderWidth, - borderRadius, - font, - fontFamily = font, - fontSize = font, - fontWeight = font, - lineHeight = font, + fontFamily, + fontSize, + fontWeight, + lineHeight, textAlign, textDecorationStyle, textDecorationLine, @@ -218,193 +384,27 @@ export const Box = memo( flexShrink, flexGrow, opacity, - ...props - }, - ref, - ) => { - const Component = animated ? Animated.View : View; - - const theme = useTheme(); - - const styles = useMemo( - () => [ - getBorderedStyles( - { - bordered, - borderedHorizontal, - borderedVertical, - borderedStart, - borderedEnd, - borderedTop, - borderedBottom, - }, - theme, - ), - getStyles( - { - display, - position, - overflow, - zIndex, - gap, - columnGap, - rowGap, - justifyContent, - alignContent, - alignItems, - alignSelf, - flexDirection, - flexWrap, - color, - background, - borderColor, - borderWidth, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderTopWidth, - borderEndWidth, - borderBottomWidth, - borderStartWidth, - elevation, - fontFamily, - fontSize, - fontWeight, - lineHeight, - textAlign, - textDecorationStyle, - textDecorationLine, - textTransform, - padding, - paddingX, - paddingY, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - margin, - marginX, - marginY, - marginTop, - marginBottom, - marginStart, - marginEnd, - userSelect, - width, - height, - minWidth, - minHeight, - maxWidth, - maxHeight, - aspectRatio, - top, - bottom, - left, - right, - transform, - flexBasis, - flexShrink, - flexGrow, - opacity, - }, - theme, - ), - elevation ? getElevationStyles(elevation, theme, background) : undefined, - pin && pinStyles[pin], - dangerouslySetBackground ? { backgroundColor: dangerouslySetBackground } : undefined, - style, - ], - [ - display, - position, - overflow, - zIndex, - gap, - columnGap, - rowGap, - justifyContent, - alignContent, - alignItems, - alignSelf, - flexDirection, - flexWrap, - color, - background, - borderColor, - borderWidth, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderTopWidth, - borderEndWidth, - borderBottomWidth, - borderStartWidth, - elevation, - fontFamily, - fontSize, - fontWeight, - lineHeight, - textAlign, - textDecorationStyle, - textDecorationLine, - textTransform, - padding, - paddingX, - paddingY, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - margin, - marginX, - marginY, - marginTop, - marginBottom, - marginStart, - marginEnd, - userSelect, - width, - height, - minWidth, - minHeight, - maxWidth, - maxHeight, - aspectRatio, - top, - bottom, - left, - right, - transform, - flexBasis, - flexShrink, - flexGrow, - opacity, - dangerouslySetBackground, - pin, - bordered, - borderedHorizontal, - borderedVertical, - borderedStart, - borderedEnd, - borderedTop, - borderedBottom, - theme, - style, - ], - ); + dangerouslySetBackground, + pin, + bordered, + borderedHorizontal, + borderedVertical, + borderedStart, + borderedEnd, + borderedTop, + borderedBottom, + theme, + style, + ], + ); - return ( - // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern - } testID={testID} {...props}> - {children} - - ); - }, - ), + return ( + // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern + } testID={testID} {...props}> + {children} + + ); + }, ); Box.displayName = 'Box'; diff --git a/packages/mobile/src/layout/Group.tsx b/packages/mobile/src/layout/Group.tsx index e44a9b390f..be60897acf 100644 --- a/packages/mobile/src/layout/Group.tsx +++ b/packages/mobile/src/layout/Group.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, type ReactElement, useMemo } from 'react'; +import React, { memo, type ReactElement, useMemo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -48,37 +48,42 @@ export type GroupProps = GroupBaseProps; * @deprecationExpectedRemoval v8 * @danger Make sure to add a `key` prop to each item. */ -export const Group = memo( - forwardRef(function Group( - { children, direction = 'vertical', divider, gap, renderItem, ...boxProps }, - forwardedRef, - ) { - const contents = useMemo( - () => - flattenAndJoinNodes({ - children, - gap, - divider, - renderItem, - direction, - Spacer, - ItemWrapper: Box, - }), - [children, direction, divider, gap, renderItem], - ); +export const Group = memo(function Group({ + ref: forwardedRef, + children, + direction = 'vertical', + divider, + gap, + renderItem, + ...boxProps +}: GroupProps & { + ref?: React.Ref; +}) { + const contents = useMemo( + () => + flattenAndJoinNodes({ + children, + gap, + divider, + renderItem, + direction, + Spacer, + ItemWrapper: Box, + }), + [children, direction, divider, gap, renderItem], + ); - return ( - - {contents} - - ); - }), -); + return ( + + {contents} + + ); +}); Group.displayName = 'Group'; diff --git a/packages/mobile/src/layout/HStack.tsx b/packages/mobile/src/layout/HStack.tsx index 5de2464b41..e624f54ab2 100644 --- a/packages/mobile/src/layout/HStack.tsx +++ b/packages/mobile/src/layout/HStack.tsx @@ -1,15 +1,16 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import { Box, type BoxProps } from './Box'; export type HStackProps = BoxProps; -export const HStack = memo( - forwardRef(function HStack( - { flexDirection = 'row', ...props }: HStackProps, - forwardedRef: React.ForwardedRef, - ) { - return ; - }), -); +export const HStack = memo(function HStack({ + ref: forwardedRef, + flexDirection = 'row', + ...props +}: HStackProps & { + ref?: React.Ref; +}) { + return ; +}); diff --git a/packages/mobile/src/layout/VStack.tsx b/packages/mobile/src/layout/VStack.tsx index 688f27e116..ceadadf528 100644 --- a/packages/mobile/src/layout/VStack.tsx +++ b/packages/mobile/src/layout/VStack.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import type { BoxProps } from './Box'; @@ -6,11 +6,12 @@ import { Box } from './Box'; export type VStackProps = BoxProps; -export const VStack = memo( - forwardRef(function VStack( - { flexDirection = 'column', ...props }: VStackProps, - forwardedRef: React.ForwardedRef, - ) { - return ; - }), -); +export const VStack = memo(function VStack({ + ref: forwardedRef, + flexDirection = 'column', + ...props +}: VStackProps & { + ref?: React.Ref; +}) { + return ; +}); diff --git a/packages/mobile/src/media/Carousel/Carousel.tsx b/packages/mobile/src/media/Carousel/Carousel.tsx index ab0b2b3ef8..c94225d950 100644 --- a/packages/mobile/src/media/Carousel/Carousel.tsx +++ b/packages/mobile/src/media/Carousel/Carousel.tsx @@ -1,5 +1,4 @@ import React, { - forwardRef, memo, useCallback, useEffect, @@ -46,131 +45,135 @@ export type CarouselProps = { * @deprecationExpectedRemoval v8 */ export const Carousel = memo( - forwardRef( - ( - { carouselRef, items, gap = 3, testID = 'Carousel', onReady, ...otherProps }, - forwardedRef, - ) => { - /** A key/value object of ids to x coordinates. i.e. { 0: 0, 1: 400, 2: 800 } */ - const [layoutMap, setLayoutMap] = useState({}); - /** ScrollRef for wrapping ScrollView */ - const [scrollRef, { scrollTo, scrollToEnd }] = useScrollTo(forwardedRef); - /** Guarantees we only fire onReady once. */ - const hasFiredOnReady = useRef(false); - /** Prevent multiple dismissals at once. */ - const isAnimating = useRef(false); - const [dismissedItems, setDismissedItems] = useState>(() => new Set()); - const resetDismissedItems = useCallback(() => { - setDismissedItems(new Set()); - }, []); + ({ + ref: forwardedRef, + carouselRef, + items, + gap = 3, + testID = 'Carousel', + onReady, + ...otherProps + }: CarouselProps & { + ref?: React.Ref; + }) => { + /** A key/value object of ids to x coordinates. i.e. { 0: 0, 1: 400, 2: 800 } */ + const [layoutMap, setLayoutMap] = useState({}); + /** ScrollRef for wrapping ScrollView */ + const [scrollRef, { scrollTo, scrollToEnd }] = useScrollTo(forwardedRef); + /** Guarantees we only fire onReady once. */ + const hasFiredOnReady = useRef(false); + /** Prevent multiple dismissals at once. */ + const isAnimating = useRef(false); + const [dismissedItems, setDismissedItems] = useState>(() => new Set()); + const resetDismissedItems = useCallback(() => { + setDismissedItems(new Set()); + }, []); - const itemsArray = useMemo( - () => items.filter((item) => !!item.key && !dismissedItems.has(item.key)), - [items, dismissedItems], - ); - /** The number of of CarouselItems */ - const childrenLength = itemsArray.length; + const itemsArray = useMemo( + () => items.filter((item) => !!item.key && !dismissedItems.has(item.key)), + [items, dismissedItems], + ); + /** The number of of CarouselItems */ + const childrenLength = itemsArray.length; - const getDismissHandler = useCallback((shouldAnimateHeight: boolean) => { - return ({ height, opacity, width, id, callbackFn }: CarouselDismissItemParams) => { - if (isAnimating.current) return; - isAnimating.current = true; - const opacityMotion = Animated.timing(opacity, opacityConfig); - const widthMotion = Animated.timing(width, sizeConfig); - const heightMotion = Animated.timing(height, sizeConfig); - Animated.parallel([ - opacityMotion, - shouldAnimateHeight ? heightMotion : widthMotion, - ]).start(() => { + const getDismissHandler = useCallback((shouldAnimateHeight: boolean) => { + return ({ height, opacity, width, id, callbackFn }: CarouselDismissItemParams) => { + if (isAnimating.current) return; + isAnimating.current = true; + const opacityMotion = Animated.timing(opacity, opacityConfig); + const widthMotion = Animated.timing(width, sizeConfig); + const heightMotion = Animated.timing(height, sizeConfig); + Animated.parallel([opacityMotion, shouldAnimateHeight ? heightMotion : widthMotion]).start( + () => { isAnimating.current = false; setDismissedItems((prev) => new Set(prev).add(id)); callbackFn?.(); - }); - }; - }, []); + }, + ); + }; + }, []); - /** Array of x coordinates for snapping the wrapping ScrollView on gesture */ - const snapPoints = useMemo(() => Object.values(layoutMap), [layoutMap]); - /** This is fired in onLayout of CarouselItem. */ - const updateLayoutMap = useCallback((value: CarouselLayoutMap) => { - setLayoutMap((prev) => ({ ...prev, ...value })); - }, []); - /** Imperatively handling scrolling Carousel to an item. LayoutMap has the index to x coordinate mapping. */ - const scrollToId = useCallback( - (id: CarouselItemId, params: ScrollToParams | undefined = {}) => { - scrollTo({ x: layoutMap[id], ...params }); - }, - [layoutMap, scrollTo], - ); - /** This object contains any internal data/methods of Carousel that we want to expose to consumers. */ - const publicData = useMemo( - () => ({ - length: childrenLength, - dismissedItems, - resetDismissedItems, - scrollToId, - scrollToEnd, + /** Array of x coordinates for snapping the wrapping ScrollView on gesture */ + const snapPoints = useMemo(() => Object.values(layoutMap), [layoutMap]); + /** This is fired in onLayout of CarouselItem. */ + const updateLayoutMap = useCallback((value: CarouselLayoutMap) => { + setLayoutMap((prev) => ({ ...prev, ...value })); + }, []); + /** Imperatively handling scrolling Carousel to an item. LayoutMap has the index to x coordinate mapping. */ + const scrollToId = useCallback( + (id: CarouselItemId, params: ScrollToParams | undefined = {}) => { + scrollTo({ x: layoutMap[id], ...params }); + }, + [layoutMap, scrollTo], + ); + /** This object contains any internal data/methods of Carousel that we want to expose to consumers. */ + const publicData = useMemo( + () => ({ + length: childrenLength, + dismissedItems, + resetDismissedItems, + scrollToId, + scrollToEnd, + }), + [childrenLength, dismissedItems, resetDismissedItems, scrollToId, scrollToEnd], + ); + /** Guarantees that we have x coordinates for each CarouselItem before triggering onReady. */ + useEffect(() => { + const isReady = !!scrollRef && childrenLength === snapPoints.length; + if (hasFiredOnReady.current || !isReady) return; + onReady?.(publicData); + hasFiredOnReady.current = true; + }, [publicData, childrenLength, onReady, scrollRef, snapPoints.length]); + /** + * Useful if you need access to carousel length or scrollToId outside of Carousel. The useCarousel hook exposes these values and requires the ref returned to be passed into Carousel's carouselRef prop. + * @example + * ``` + * const carouselRef = useCarousel() + * const handlePress = () => carouselRef.current.scrollToId('item3'); + * + * + * ``` + */ + useImperativeHandle(carouselRef, () => publicData, [publicData]); + /** Loop over our children and create CarouselItem component. */ + const content = useMemo( + () => + itemsArray.map((child, index) => { + const key = child.key ?? index; + const shouldAnimateHeight = itemsArray.length === 1; + const isLastItem = index === itemsArray.length - 1; + return ( + + {child} + + ); }), - [childrenLength, dismissedItems, resetDismissedItems, scrollToId, scrollToEnd], - ); - /** Guarantees that we have x coordinates for each CarouselItem before triggering onReady. */ - useEffect(() => { - const isReady = !!scrollRef && childrenLength === snapPoints.length; - if (hasFiredOnReady.current || !isReady) return; - onReady?.(publicData); - hasFiredOnReady.current = true; - }, [publicData, childrenLength, onReady, scrollRef, snapPoints.length]); - /** - * Useful if you need access to carousel length or scrollToId outside of Carousel. The useCarousel hook exposes these values and requires the ref returned to be passed into Carousel's carouselRef prop. - * @example - * ``` - * const carouselRef = useCarousel() - * const handlePress = () => carouselRef.current.scrollToId('item3'); - * - * - * ``` - */ - useImperativeHandle(carouselRef, () => publicData, [publicData]); - /** Loop over our children and create CarouselItem component. */ - const content = useMemo( - () => - itemsArray.map((child, index) => { - const key = child.key ?? index; - const shouldAnimateHeight = itemsArray.length === 1; - const isLastItem = index === itemsArray.length - 1; - return ( - - {child} - - ); - }), - [itemsArray, getDismissHandler, gap, updateLayoutMap], - ); + [itemsArray, getDismissHandler, gap, updateLayoutMap], + ); - return ( - - {content} - - ); - }, - ), + return ( + + {content} + + ); + }, ); const styles = StyleSheet.create({ diff --git a/packages/mobile/src/motion/ColorSurge.tsx b/packages/mobile/src/motion/ColorSurge.tsx index d59aeca060..feec56303d 100644 --- a/packages/mobile/src/motion/ColorSurge.tsx +++ b/packages/mobile/src/motion/ColorSurge.tsx @@ -1,12 +1,4 @@ -import React, { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useRef, - useState, -} from 'react'; +import React, { memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { Animated } from 'react-native'; import type { ForwardedRef } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -35,45 +27,46 @@ export type ColorSurgeTypes = ColorSurgeBaseProps; /** * Please consult with the motion team in #ask-motion before using this component. */ -export const ColorSurge = memo( - forwardRef(function ColorSurge( - { background = 'bgPrimary', disableAnimateOnMount = false }: ColorSurgeTypes, - ref: ForwardedRef, - ) { - const [backgroundState, setBackgroundState] = useState(background); - const opacity = useRef(new Animated.Value(colorSurgeEnterConfig.fromValue as number)).current; +export const ColorSurge = memo(function ColorSurge({ + ref, + background = 'bgPrimary', + disableAnimateOnMount = false, +}: ColorSurgeTypes & { + ref?: React.Ref; +}) { + const [backgroundState, setBackgroundState] = useState(background); + const opacity = useRef(new Animated.Value(colorSurgeEnterConfig.fromValue as number)).current; - const playAnimation = useCallback( - async (backgroundParam?: ThemeVars.Color) => { - if (backgroundParam) { - setBackgroundState(backgroundParam); - } - Animated.sequence([ - /** - * Casting to workaround value type mismatch, string value is not allowed for mobile - * TODO: fix value mismatch and remove casting - */ - Animated.timing(opacity, convertMotionConfig(colorSurgeEnterConfig as MotionBaseSpec)), - Animated.timing(opacity, convertMotionConfig(colorSurgeExitConfig as MotionBaseSpec)), - ]).start(); - }, - [opacity], - ); + const playAnimation = useCallback( + async (backgroundParam?: ThemeVars.Color) => { + if (backgroundParam) { + setBackgroundState(backgroundParam); + } + Animated.sequence([ + /** + * Casting to workaround value type mismatch, string value is not allowed for mobile + * TODO: fix value mismatch and remove casting + */ + Animated.timing(opacity, convertMotionConfig(colorSurgeEnterConfig as MotionBaseSpec)), + Animated.timing(opacity, convertMotionConfig(colorSurgeExitConfig as MotionBaseSpec)), + ]).start(); + }, + [opacity], + ); - useImperativeHandle( - ref, - () => ({ - play: playAnimation, - }), - [playAnimation], - ); + useImperativeHandle( + ref, + () => ({ + play: playAnimation, + }), + [playAnimation], + ); - useEffect(() => { - if (!disableAnimateOnMount) { - void playAnimation(); - } - }, [playAnimation, disableAnimateOnMount]); + useEffect(() => { + if (!disableAnimateOnMount) { + void playAnimation(); + } + }, [playAnimation, disableAnimateOnMount]); - return ; - }), -); + return ; +}); diff --git a/packages/mobile/src/motion/Pulse.tsx b/packages/mobile/src/motion/Pulse.tsx index 8de78f81e4..02c08d188f 100644 --- a/packages/mobile/src/motion/Pulse.tsx +++ b/packages/mobile/src/motion/Pulse.tsx @@ -1,12 +1,4 @@ -import React, { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useRef, - useState, -} from 'react'; +import React, { memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { Animated } from 'react-native'; import type { ForwardedRef } from 'react'; import { pulseTransitionConfig, pulseVariantOpacity } from '@coinbase/cds-common/motion/hint'; @@ -47,67 +39,65 @@ export type PulseProps = PulseBaseProps; /** * Please consult with the motion team in #ask-motion before using this component. */ -export const Pulse = memo( - forwardRef(function Pulse( - { - children, - variant = 'moderate', - disableAnimateOnMount = false, - iterations, - motionConfig, - }: PulseProps, - ref: ForwardedRef, - ) { - const [variantState, setVariantState] = useState(variant); - const opacity = useRef(new Animated.Value(0)).current; +export const Pulse = memo(function Pulse({ + ref, + children, + variant = 'moderate', + disableAnimateOnMount = false, + iterations, + motionConfig, +}: PulseProps & { + ref?: React.Ref; +}) { + const [variantState, setVariantState] = useState(variant); + const opacity = useRef(new Animated.Value(0)).current; - const interpolatedOpacity = opacity.interpolate({ - inputRange: [0, 0.5, 1], - outputRange: [1, pulseVariantOpacity[variantState], 1], - }); + const interpolatedOpacity = opacity.interpolate({ + inputRange: [0, 0.5, 1], + outputRange: [1, pulseVariantOpacity[variantState], 1], + }); - const stopAnimation = useCallback(() => { - opacity.stopAnimation(); - opacity.setValue(0); - }, [opacity]); + const stopAnimation = useCallback(() => { + opacity.stopAnimation(); + opacity.setValue(0); + }, [opacity]); - const playAnimation = useCallback( - async (variantParam?: PulseVariant) => { - if (variantParam) { - setVariantState(variantParam); - } + const playAnimation = useCallback( + async (variantParam?: PulseVariant) => { + if (variantParam) { + setVariantState(variantParam); + } - stopAnimation(); - Animated.loop( - Animated.timing( - opacity, - convertMotionConfig({ - ...pulseTransitionConfig, - ...(motionConfig || {}), - toValue: 1, - }), - ), - { iterations: iterations === 0 ? 1 : iterations }, - ).start(); - }, - [iterations, opacity, stopAnimation, motionConfig], - ); + stopAnimation(); + Animated.loop( + Animated.timing( + opacity, + convertMotionConfig({ + ...pulseTransitionConfig, + ...(motionConfig || {}), + toValue: 1, + }), + ), + { iterations: iterations === 0 ? 1 : iterations }, + ).start(); + }, + [iterations, opacity, stopAnimation, motionConfig], + ); - useEffect(() => { - if (!disableAnimateOnMount) { - void playAnimation(); - } - }, [playAnimation, disableAnimateOnMount]); + useEffect(() => { + if (!disableAnimateOnMount) { + void playAnimation(); + } + }, [playAnimation, disableAnimateOnMount]); - useImperativeHandle( - ref, - () => ({ - play: playAnimation, - stop: stopAnimation, - }), - [playAnimation, stopAnimation], - ); + useImperativeHandle( + ref, + () => ({ + play: playAnimation, + stop: stopAnimation, + }), + [playAnimation, stopAnimation], + ); - return {children}; - }), -); + return {children}; +}); diff --git a/packages/mobile/src/motion/Shake.tsx b/packages/mobile/src/motion/Shake.tsx index 4a2b054e92..567ce7ec27 100644 --- a/packages/mobile/src/motion/Shake.tsx +++ b/packages/mobile/src/motion/Shake.tsx @@ -1,11 +1,4 @@ -import React, { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useRef, -} from 'react'; +import React, { memo, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; import { Animated } from 'react-native'; import type { ForwardedRef } from 'react'; import { shakeTransitionConfig, shakeTranslateX } from '@coinbase/cds-common/motion/hint'; @@ -28,49 +21,48 @@ export type ShakeProps = ShakeBaseProps; /** * Please consult with the motion team in #ask-motion before using this component. */ -export const Shake = memo( - forwardRef(function Shake( - { children, disableAnimateOnMount = false }: ShakeProps, - ref: ForwardedRef, - ) { - const translateX = useRef(new Animated.Value(0)).current; +export const Shake = memo(function Shake({ + ref, + children, + disableAnimateOnMount = false, +}: ShakeProps & { + ref?: React.Ref; +}) { + const translateX = useRef(new Animated.Value(0)).current; - const interpolatedX = translateX.interpolate({ - inputRange: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], - outputRange: shakeTranslateX, - }); - - const playAnimation = useCallback(async () => { - void Haptics.warningNotification(); - Animated.timing( - translateX, - convertMotionConfig({ ...shakeTransitionConfig, toValue: 9 }), - ).start(({ finished }) => { - // reset value so it can be animated again - if (finished) { - translateX.setValue(0); - } - }); - }, [translateX]); + const interpolatedX = translateX.interpolate({ + inputRange: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + outputRange: shakeTranslateX, + }); - useEffect(() => { - if (!disableAnimateOnMount) { - void playAnimation(); + const playAnimation = useCallback(async () => { + void Haptics.warningNotification(); + Animated.timing( + translateX, + convertMotionConfig({ ...shakeTransitionConfig, toValue: 9 }), + ).start(({ finished }) => { + // reset value so it can be animated again + if (finished) { + translateX.setValue(0); } - }, [playAnimation, disableAnimateOnMount]); + }); + }, [translateX]); + + useEffect(() => { + if (!disableAnimateOnMount) { + void playAnimation(); + } + }, [playAnimation, disableAnimateOnMount]); - useImperativeHandle( - ref, - () => ({ - play: playAnimation, - }), - [playAnimation], - ); + useImperativeHandle( + ref, + () => ({ + play: playAnimation, + }), + [playAnimation], + ); - return ( - - {children} - - ); - }), -); + return ( + {children} + ); +}); diff --git a/packages/mobile/src/multi-content-module/MultiContentModule.tsx b/packages/mobile/src/multi-content-module/MultiContentModule.tsx index d1e2833ee8..9e3f3f38fd 100644 --- a/packages/mobile/src/multi-content-module/MultiContentModule.tsx +++ b/packages/mobile/src/multi-content-module/MultiContentModule.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, isValidElement, memo } from 'react'; +import React, { isValidElement, memo } from 'react'; import type { View } from 'react-native'; import type { IllustrationPictogramNames } from '@coinbase/cds-common/types'; @@ -31,74 +31,72 @@ export type MultiContentModuleProps = MultiContentModuleBaseProps & { onActionPress?: PressableProps['onPress']; } & Omit; -export const MultiContentModule = memo( - forwardRef(function MultiContentModule( - { - pictogram, - title, - description, - children, - action, - onActionPress, - actionAccessibilityLabel, - end, - bordered = false, - testID, - accessibilityLabel, - style, - ...props - }: MultiContentModuleProps, - ref: React.Ref, - ) { - return ( - - {typeof pictogram === 'string' ? ( - +export const MultiContentModule = memo(function MultiContentModule({ + ref, + pictogram, + title, + description, + children, + action, + onActionPress, + actionAccessibilityLabel, + end, + bordered = false, + testID, + accessibilityLabel, + style, + ...props +}: MultiContentModuleProps & { + ref?: React.Ref; +}) { + return ( + + {typeof pictogram === 'string' ? ( + + ) : ( + pictogram + )} + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + {typeof description === 'string' ? ( + + {description} + + ) : ( + description + )} + + {children} + + {action && + (isValidElement(action) ? ( + action ) : ( - pictogram - )} - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {typeof description === 'string' ? ( - - {description} - - ) : ( - description - )} - - {children} - - {action && - (isValidElement(action) ? ( - action - ) : ( - - ))} - {end} - - ); - }), -); + + ))} + {end} + + ); +}); diff --git a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberAffixSection.tsx b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberAffixSection.tsx index 8cd6de3aa9..e268b1da53 100644 --- a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberAffixSection.tsx +++ b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberAffixSection.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { View } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -13,38 +13,36 @@ import type { const AnimatedText = Animated.createAnimatedComponent(Text); export const DefaultRollingNumberAffixSection: RollingNumberAffixSectionComponent = memo( - forwardRef( - ( - { - children, - textProps, - style, - styles, - justifyContent = 'flex-start', - ...props - }: RollingNumberAffixSectionProps, - ref, - ) => { - const containerStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - const textNode = useMemo( - () => ( - - {children} - - ), - [children, textProps, styles?.text], - ); - return ( - - {typeof children === 'string' || typeof children === 'number' ? textNode : children} - - ); - }, - ), + ({ + ref, + children, + textProps, + style, + styles, + justifyContent = 'flex-start', + ...props + }: RollingNumberAffixSectionProps & { + ref?: React.Ref; + }) => { + const containerStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + const textNode = useMemo( + () => ( + + {children} + + ), + [children, textProps, styles?.text], + ); + return ( + + {typeof children === 'string' || typeof children === 'number' ? textNode : children} + + ); + }, ); diff --git a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx index d7cd8d0af3..7ae687f661 100644 --- a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx +++ b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberDigit.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, type View } from 'react-native'; import Animated, { type EntryAnimationsValues, @@ -96,119 +96,112 @@ const baseStylesheet = StyleSheet.create({ * custom animation worklets. Web uses imperative opacity crossfades on DOM sections. */ export const DefaultRollingNumberDigit: RollingNumberDigitComponent = memo( - forwardRef( - ( - { - value, - digitHeight, - initialValue = value, - textProps, - style, - styles, - transitionConfig, - digitTransitionVariant = 'every', - direction, - RollingNumberMaskComponent = DefaultRollingNumberMask, - ...props - }, - ref, - ) => { - const [singleVariantCurrentValue, setCurrentValue] = useState(initialValue); - - const position = useSharedValue(initialValue * digitHeight * -1); - const prevValue = useRef(initialValue); - - const isSingleVariant = useMemo( - () => digitTransitionVariant === 'single', - [digitTransitionVariant], - ); - - const isGoingUp = useMemo(() => direction === 'up', [direction]); - - // Single variant needs to re-render to give time for exit animation direction to be updated - useEffect(() => { - if (value !== singleVariantCurrentValue) { - setCurrentValue(value); - } - }, [value, singleVariantCurrentValue]); - - // Every variant needs to update the position of the digit immediately - useEffect(() => { - if (prevValue.current === value) return; - - const newPosition = value * digitHeight * -1; - const yConfig = transitionConfig?.y ?? defaultTransitionConfig.y; - - if (yConfig?.type === 'timing') { - position.value = withTiming(newPosition, yConfig); - } else { - position.value = withSpring(newPosition, yConfig); - } - prevValue.current = value; - }, [digitHeight, position, transitionConfig?.y, value]); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: position.value }], - })); - - const containerStyle = useMemo( - () => [ - baseStylesheet.digitContainer, - !isSingleVariant && animatedStyle, - style, - styles?.root, - ], - [animatedStyle, isSingleVariant, style, styles?.root], - ); - - const singleVariantEnterTransition = useMemo( - () => createTransitionAnimation(true, isGoingUp, transitionConfig), - [isGoingUp, transitionConfig], - ); - - const singleVariantExitTransition = useMemo( - () => createTransitionAnimation(false, isGoingUp, transitionConfig), - [isGoingUp, transitionConfig], - ); - - // LayoutAnimationConfig disables mount/unmount animations on the digit container itself - // (e.g. when digits are added/removed going from $1,000 to $10,000 or vice versa). - // AnimatedText entering/exiting props handle value change animations separately. - return ( - - - - {isSingleVariant ? ( + ({ + ref, + value, + digitHeight, + initialValue = value, + textProps, + style, + styles, + transitionConfig, + digitTransitionVariant = 'every', + direction, + RollingNumberMaskComponent = DefaultRollingNumberMask, + ...props + }: RollingNumberDigitProps & { + ref?: React.Ref; + }) => { + const [singleVariantCurrentValue, setCurrentValue] = useState(initialValue); + + const position = useSharedValue(initialValue * digitHeight * -1); + const prevValue = useRef(initialValue); + + const isSingleVariant = useMemo( + () => digitTransitionVariant === 'single', + [digitTransitionVariant], + ); + + const isGoingUp = useMemo(() => direction === 'up', [direction]); + + // Single variant needs to re-render to give time for exit animation direction to be updated + useEffect(() => { + if (value !== singleVariantCurrentValue) { + setCurrentValue(value); + } + }, [value, singleVariantCurrentValue]); + + // Every variant needs to update the position of the digit immediately + useEffect(() => { + if (prevValue.current === value) return; + + const newPosition = value * digitHeight * -1; + const yConfig = transitionConfig?.y ?? defaultTransitionConfig.y; + + if (yConfig?.type === 'timing') { + position.value = withTiming(newPosition, yConfig); + } else { + position.value = withSpring(newPosition, yConfig); + } + prevValue.current = value; + }, [digitHeight, position, transitionConfig?.y, value]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: position.value }], + })); + + const containerStyle = useMemo( + () => [baseStylesheet.digitContainer, !isSingleVariant && animatedStyle, style, styles?.root], + [animatedStyle, isSingleVariant, style, styles?.root], + ); + + const singleVariantEnterTransition = useMemo( + () => createTransitionAnimation(true, isGoingUp, transitionConfig), + [isGoingUp, transitionConfig], + ); + + const singleVariantExitTransition = useMemo( + () => createTransitionAnimation(false, isGoingUp, transitionConfig), + [isGoingUp, transitionConfig], + ); + + // LayoutAnimationConfig disables mount/unmount animations on the digit container itself + // (e.g. when digits are added/removed going from $1,000 to $10,000 or vice versa). + // AnimatedText entering/exiting props handle value change animations separately. + return ( + + + + {isSingleVariant ? ( + + {singleVariantCurrentValue} + + ) : ( + digits.map((digit) => ( - {singleVariantCurrentValue} + {digit} - ) : ( - digits.map((digit) => ( - - {digit} - - )) - )} - - - - ); - }, - ), + )) + )} + + + + ); + }, ); diff --git a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberMask.tsx b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberMask.tsx index b7e1c5ca03..76f2fd3dc8 100644 --- a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberMask.tsx +++ b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberMask.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { StyleSheet, type View } from 'react-native'; import { HStack } from '../../layout/HStack'; @@ -13,12 +13,19 @@ const baseStylesheet = StyleSheet.create({ }); export const DefaultRollingNumberMask: RollingNumberMaskComponent = memo( - forwardRef(({ children, style, ...props }, ref) => { + ({ + ref, + children, + style, + ...props + }: RollingNumberMaskProps & { + ref?: React.Ref; + }) => { const containerStyle = useMemo(() => [baseStylesheet.mask, style], [style]); return ( {children} ); - }), + }, ); diff --git a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberSymbol.tsx b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberSymbol.tsx index 2ffdbac028..020b13e889 100644 --- a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberSymbol.tsx +++ b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberSymbol.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { View } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -10,22 +10,29 @@ import type { RollingNumberSymbolComponent, RollingNumberSymbolProps } from './R const AnimatedText = Animated.createAnimatedComponent(Text); export const DefaultRollingNumberSymbol: RollingNumberSymbolComponent = memo( - forwardRef( - ({ value, textProps, style, styles, ...props }, ref) => { - const containerStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - const textNode = useMemo( - () => ( - - {value} - - ), - [value, textProps, styles?.text], - ); - return ( - - {textNode} - - ); - }, - ), + ({ + ref, + value, + textProps, + style, + styles, + ...props + }: RollingNumberSymbolProps & { + ref?: React.Ref; + }) => { + const containerStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + const textNode = useMemo( + () => ( + + {value} + + ), + [value, textProps, styles?.text], + ); + return ( + + {textNode} + + ); + }, ); diff --git a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberValueSection.tsx b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberValueSection.tsx index 6ef2f016da..9215f84583 100644 --- a/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberValueSection.tsx +++ b/packages/mobile/src/numbers/RollingNumber/DefaultRollingNumberValueSection.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import type { Key } from 'react'; import type { View } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -20,154 +20,152 @@ const AnimatedText = Animated.createAnimatedComponent(Text); const isDigit = (char: string) => digits.includes(parseInt(char)); export const DefaultRollingNumberValueSection: RollingNumberValueSectionComponent = memo( - forwardRef( - ( - { - intlNumberParts, - textProps, - digitHeight, - formattedValue, - RollingNumberDigitComponent = DefaultRollingNumberDigit, - RollingNumberSymbolComponent = DefaultRollingNumberSymbol, - RollingNumberMaskComponent = DefaultRollingNumberMask, - style, - styles, - justifyContent = 'flex-start', - transitionConfig, - digitTransitionVariant, - direction, - ...props - }: RollingNumberValueSectionProps, - ref, - ) => { - const [numberSectionHasRendered, setValueSectionHasRendered] = useState(false); - - const containerStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + ({ + ref, + intlNumberParts, + textProps, + digitHeight, + formattedValue, + RollingNumberDigitComponent = DefaultRollingNumberDigit, + RollingNumberSymbolComponent = DefaultRollingNumberSymbol, + RollingNumberMaskComponent = DefaultRollingNumberMask, + style, + styles, + justifyContent = 'flex-start', + transitionConfig, + digitTransitionVariant, + direction, + ...props + }: RollingNumberValueSectionProps & { + ref?: React.Ref; + }) => { + const [numberSectionHasRendered, setValueSectionHasRendered] = useState(false); - // fallback digit is used when the measurement is not complete - const fallbackDigit = useCallback( - (digit: number, key: Key) => ( - - {digit} - - ), - [textProps, styles?.text], - ); + const containerStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - const intlPartsDigits = useMemo( - () => - intlNumberParts.map((part) => { - if ( - (part.type !== 'integer' && part.type !== 'fraction') || - typeof part.value !== 'number' - ) { - return ( - - ); - } + // fallback digit is used when the measurement is not complete + const fallbackDigit = useCallback( + (digit: number, key: Key) => ( + + {digit} + + ), + [textProps, styles?.text], + ); - if (!digitHeight) return fallbackDigit(part.value, part.key); + const intlPartsDigits = useMemo( + () => + intlNumberParts.map((part) => { + if ( + (part.type !== 'integer' && part.type !== 'fraction') || + typeof part.value !== 'number' + ) { return ( - setValueSectionHasRendered(true)} + ); - }), - [ - numberSectionHasRendered, - setValueSectionHasRendered, - intlNumberParts, - digitHeight, - digitTransitionVariant, - direction, - RollingNumberDigitComponent, - RollingNumberSymbolComponent, - styles?.text, - textProps, - fallbackDigit, - justifyContent, - transitionConfig, - RollingNumberMaskComponent, - ], - ); + } - const formattedValueDigits = useMemo( - () => - formattedValue?.split('').map((char, index) => { - if (!isDigit(char)) { - return ( - - ); - } + if (!digitHeight) return fallbackDigit(part.value, part.key); + return ( + setValueSectionHasRendered(true)} + styles={{ text: styles?.text }} + textProps={textProps} + transitionConfig={transitionConfig} + value={part.value} + /> + ); + }), + [ + numberSectionHasRendered, + setValueSectionHasRendered, + intlNumberParts, + digitHeight, + digitTransitionVariant, + direction, + RollingNumberDigitComponent, + RollingNumberSymbolComponent, + styles?.text, + textProps, + fallbackDigit, + justifyContent, + transitionConfig, + RollingNumberMaskComponent, + ], + ); - if (!digitHeight) return fallbackDigit(parseInt(char), index); + const formattedValueDigits = useMemo( + () => + formattedValue?.split('').map((char, index) => { + if (!isDigit(char)) { return ( - setValueSectionHasRendered(true)} + justifyContent={justifyContent} styles={{ text: styles?.text }} textProps={textProps} - transitionConfig={transitionConfig} - value={parseInt(char)} + value={char} /> ); - }), - [ - numberSectionHasRendered, - setValueSectionHasRendered, - formattedValue, - RollingNumberDigitComponent, - RollingNumberSymbolComponent, - styles?.text, - digitHeight, - digitTransitionVariant, - direction, - textProps, - fallbackDigit, - justifyContent, - transitionConfig, - RollingNumberMaskComponent, - ], - ); + } + + if (!digitHeight) return fallbackDigit(parseInt(char), index); + return ( + setValueSectionHasRendered(true)} + styles={{ text: styles?.text }} + textProps={textProps} + transitionConfig={transitionConfig} + value={parseInt(char)} + /> + ); + }), + [ + numberSectionHasRendered, + setValueSectionHasRendered, + formattedValue, + RollingNumberDigitComponent, + RollingNumberSymbolComponent, + styles?.text, + digitHeight, + digitTransitionVariant, + direction, + textProps, + fallbackDigit, + justifyContent, + transitionConfig, + RollingNumberMaskComponent, + ], + ); - return ( - - {formattedValue ? formattedValueDigits : intlPartsDigits} - - ); - }, - ), + return ( + + {formattedValue ? formattedValueDigits : intlPartsDigits} + + ); + }, ); diff --git a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx index 666d3d38d7..52bb6055e0 100644 --- a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx +++ b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { type LayoutChangeEvent, type StyleProp, @@ -404,7 +404,12 @@ export type RollingNumberProps = TextProps & }; export const RollingNumber = memo( - forwardRef((_props: RollingNumberProps, ref) => { + ({ + ref, + ..._props + }: RollingNumberProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('RollingNumber', _props); const { value, @@ -736,5 +741,5 @@ export const RollingNumber = memo( ); - }), + }, ); diff --git a/packages/mobile/src/numpad/Numpad.tsx b/packages/mobile/src/numpad/Numpad.tsx index d7a66ce080..c1297842bd 100644 --- a/packages/mobile/src/numpad/Numpad.tsx +++ b/packages/mobile/src/numpad/Numpad.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { type SharedProps } from '@coinbase/cds-common'; @@ -71,7 +71,12 @@ const styles = StyleSheet.create({ }); export const Numpad = memo( - forwardRef((_props: NumpadProps, forwardedRef: React.ForwardedRef) => { + ({ + ref: forwardedRef, + ..._props + }: NumpadProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Numpad', _props); const { separator = '.', @@ -144,7 +149,7 @@ export const Numpad = memo( {action} ); - }), + }, ); const NumpadButton = memo(function NumpadButton({ diff --git a/packages/mobile/src/overlays/Alert.tsx b/packages/mobile/src/overlays/Alert.tsx index 4e1a3cdb2f..160bbf274a 100644 --- a/packages/mobile/src/overlays/Alert.tsx +++ b/packages/mobile/src/overlays/Alert.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'; +import { memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'; import { Modal as RNModal, type ViewStyle } from 'react-native'; import type { ButtonVariant, @@ -62,7 +62,12 @@ export type AlertBaseProps = SharedProps & export type AlertProps = AlertBaseProps; export const Alert = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: AlertProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Alert', _props); const { title, @@ -201,5 +206,5 @@ export const Alert = memo( ); - }), + }, ); diff --git a/packages/mobile/src/overlays/Toast.tsx b/packages/mobile/src/overlays/Toast.tsx index b37ab905a2..d95a4866aa 100644 --- a/packages/mobile/src/overlays/Toast.tsx +++ b/packages/mobile/src/overlays/Toast.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle } from 'react'; +import React, { memo, useCallback, useEffect, useImperativeHandle } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { ToastBaseProps as CommonToastBaseProps, @@ -20,7 +20,12 @@ export type ToastBaseProps = CommonToastBaseProps; export type ToastProps = ToastBaseProps & BoxProps; export const Toast = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: ToastProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Toast', _props); const { text, @@ -127,5 +132,5 @@ export const Toast = memo( ); - }), + }, ); diff --git a/packages/mobile/src/overlays/drawer/Drawer.tsx b/packages/mobile/src/overlays/drawer/Drawer.tsx index f2482ecbee..4223517d4c 100644 --- a/packages/mobile/src/overlays/drawer/Drawer.tsx +++ b/packages/mobile/src/overlays/drawer/Drawer.tsx @@ -1,5 +1,4 @@ import React, { - forwardRef, memo, useCallback, useEffect, @@ -138,7 +137,12 @@ const overlayContentContextValue: OverlayContentContextValue = { }; export const Drawer = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: DrawerProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Drawer', _props); const { children, @@ -367,5 +371,5 @@ export const Drawer = memo( ); - }), + }, ); diff --git a/packages/mobile/src/overlays/modal/Modal.tsx b/packages/mobile/src/overlays/modal/Modal.tsx index fb005a22bd..e53553f211 100644 --- a/packages/mobile/src/overlays/modal/Modal.tsx +++ b/packages/mobile/src/overlays/modal/Modal.tsx @@ -1,12 +1,4 @@ -import React, { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useState, -} from 'react'; +import React, { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Modal as RNModal, Platform, StatusBar, StyleSheet } from 'react-native'; import type { ModalProps as RNModalProps, ViewStyle } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -50,7 +42,12 @@ const overlayContentContextValue: OverlayContentContextValue = { }; export const Modal = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: ModalProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Modal', _props); const props = mergedProps; const { @@ -134,7 +131,7 @@ export const Modal = memo( ); - }), + }, ); const styles = StyleSheet.create({ diff --git a/packages/mobile/src/overlays/tray/Tray.tsx b/packages/mobile/src/overlays/tray/Tray.tsx index cc1872c5f4..a081796466 100644 --- a/packages/mobile/src/overlays/tray/Tray.tsx +++ b/packages/mobile/src/overlays/tray/Tray.tsx @@ -1,6 +1,5 @@ import React, { createContext, - forwardRef, memo, useCallback, useContext, @@ -70,129 +69,132 @@ export const TrayContext = createContext<{ titleHeight: 0, }); -export const Tray = memo( - forwardRef(function Tray(_props, ref) { - const mergedProps = useComponentConfig('Tray', _props); - const { - children, - title, - header, - headerElevation, - footer, - onVisibilityChange, - handleBarVariant = 'outside', - verticalDrawerPercentageOfView = defaultVerticalDrawerPercentageOfView, - styles, - ...props - } = mergedProps; - const [titleHeight, setTitleHeight] = useState(0); - const isInsideHandleBar = handleBarVariant === 'inside'; - const isTitleString = typeof title === 'string'; +export const Tray = memo(function Tray({ + ref, + ..._props +}: TrayProps & { + ref?: React.Ref; +}) { + const mergedProps = useComponentConfig('Tray', _props); + const { + children, + title, + header, + headerElevation, + footer, + onVisibilityChange, + handleBarVariant = 'outside', + verticalDrawerPercentageOfView = defaultVerticalDrawerPercentageOfView, + styles, + ...props + } = mergedProps; + const [titleHeight, setTitleHeight] = useState(0); + const isInsideHandleBar = handleBarVariant === 'inside'; + const isTitleString = typeof title === 'string'; - const { contentStyle, headerStyle, titleStyle, drawerStyles } = useMemo(() => { - const { - content: contentStyle, - header: headerStyle, - title: titleStyle, - ...drawerStyles - } = styles ?? {}; - return { contentStyle, headerStyle, titleStyle, drawerStyles }; - }, [styles]); + const { contentStyle, headerStyle, titleStyle, drawerStyles } = useMemo(() => { + const { + content: contentStyle, + header: headerStyle, + title: titleStyle, + ...drawerStyles + } = styles ?? {}; + return { contentStyle, headerStyle, titleStyle, drawerStyles }; + }, [styles]); - const onTitleLayout = useCallback( - (event: LayoutChangeEvent) => { - if (!title) return; - setTitleHeight(event.nativeEvent.layout.height); - }, - [title], - ); + const onTitleLayout = useCallback( + (event: LayoutChangeEvent) => { + if (!title) return; + setTitleHeight(event.nativeEvent.layout.height); + }, + [title], + ); - const renderChildren: TrayRenderChildren = useCallback( - ({ handleClose }) => { - const content = typeof children === 'function' ? children({ handleClose }) : children; - const headerContent = typeof header === 'function' ? header({ handleClose }) : header; - const footerContent = typeof footer === 'function' ? footer({ handleClose }) : footer; + const renderChildren: TrayRenderChildren = useCallback( + ({ handleClose }) => { + const content = typeof children === 'function' ? children({ handleClose }) : children; + const headerContent = typeof header === 'function' ? header({ handleClose }) : header; + const footerContent = typeof footer === 'function' ? footer({ handleClose }) : footer; - return ( - - {(title || headerContent) && ( - - {title && ( - - {isTitleString ? ( - - {title} - - ) : ( - title - )} - - )} - {headerContent} - - )} - - {content} + return ( + + {(title || headerContent) && ( + + {title && ( + + {isTitleString ? ( + + {title} + + ) : ( + title + )} + + )} + {headerContent} - {footerContent} - - ); - }, - [ - title, - isTitleString, - contentStyle, - onTitleLayout, - isInsideHandleBar, - headerElevation, - headerStyle, - titleStyle, - header, - children, - footer, - ], - ); + )} + + {content} + + {footerContent} + + ); + }, + [ + title, + isTitleString, + contentStyle, + onTitleLayout, + isInsideHandleBar, + headerElevation, + headerStyle, + titleStyle, + header, + children, + footer, + ], + ); - useEffect(() => { - onVisibilityChange?.('visible'); - return () => { - onVisibilityChange?.('hidden'); - }; - }, [onVisibilityChange]); + useEffect(() => { + onVisibilityChange?.('visible'); + return () => { + onVisibilityChange?.('hidden'); + }; + }, [onVisibilityChange]); - const trayContextValue = useMemo( - () => ({ verticalDrawerPercentageOfView, titleHeight }), - [verticalDrawerPercentageOfView, titleHeight], - ); + const trayContextValue = useMemo( + () => ({ verticalDrawerPercentageOfView, titleHeight }), + [verticalDrawerPercentageOfView, titleHeight], + ); - return ( - - - {renderChildren} - - - ); - }), -); + return ( + + + {renderChildren} + + + ); +}); /** * @deprecated Redundant component. This will be removed in a future major release. diff --git a/packages/mobile/src/page/PageFooter.tsx b/packages/mobile/src/page/PageFooter.tsx index 713d6abfab..9e3997eac9 100644 --- a/packages/mobile/src/page/PageFooter.tsx +++ b/packages/mobile/src/page/PageFooter.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageFooterHeight } from '@coinbase/cds-common/tokens/page'; @@ -28,7 +28,12 @@ export type PageFooterBaseProps = SharedProps & export type PageFooterProps = PageFooterBaseProps & BoxProps; export const PageFooter = memo( - forwardRef((_props: PageFooterProps, ref: React.ForwardedRef) => { + ({ + ref, + ..._props + }: PageFooterProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('PageFooter', _props); const { action, legalText, ...props } = mergedProps; return ( @@ -53,5 +58,5 @@ export const PageFooter = memo( )} ); - }), + }, ); diff --git a/packages/mobile/src/page/PageHeader.tsx b/packages/mobile/src/page/PageHeader.tsx index 6a1314529f..aa886d101a 100644 --- a/packages/mobile/src/page/PageHeader.tsx +++ b/packages/mobile/src/page/PageHeader.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageHeaderHeight } from '@coinbase/cds-common/tokens/page'; @@ -48,7 +48,12 @@ export type PageHeaderProps = PageHeaderBaseProps & }; export const PageHeader = memo( - forwardRef((_props: PageHeaderProps, ref: React.ForwardedRef) => { + ({ + ref, + ..._props + }: PageHeaderProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('PageHeader', _props); const { start, title, end, styles, style, ...props } = mergedProps; const isMultiRow = useMemo(() => Boolean(start && title && end), [start, end, title]); @@ -90,5 +95,5 @@ export const PageHeader = memo( )} ); - }), + }, ); diff --git a/packages/mobile/src/section-header/SectionHeader.tsx b/packages/mobile/src/section-header/SectionHeader.tsx index 98b41b0342..97c4aa59a4 100644 --- a/packages/mobile/src/section-header/SectionHeader.tsx +++ b/packages/mobile/src/section-header/SectionHeader.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import type { SectionHeaderProps } from '@coinbase/cds-common'; import type { IconName } from '@coinbase/cds-icons'; @@ -7,61 +7,59 @@ import { Icon } from '../icons'; import { HStack, VStack } from '../layout'; import { Text } from '../typography/Text'; -export const SectionHeader = memo( - forwardRef(function SectionHeader( - { - title, - start, - icon, - iconActive, - testID, - balance, - description, - end, - accessibilityLabel, - padding = 2, - ...props - }: SectionHeaderProps, - ref: React.Ref, - ) { - return ( - - - - {!!start && start} - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {typeof icon === 'string' ? ( - - ) : ( - icon - )} - - {typeof balance === 'string' ? {balance} : balance} - {typeof description === 'string' ? ( - - {description} +export const SectionHeader = memo(function SectionHeader({ + ref, + title, + start, + icon, + iconActive, + testID, + balance, + description, + end, + accessibilityLabel, + padding = 2, + ...props +}: SectionHeaderProps & { + ref?: React.Ref; +}) { + return ( + + + + {!!start && start} + {typeof title === 'string' ? ( + + {title} ) : ( - description + title )} - - {!!end && {end}} - - ); - }), -); + {typeof icon === 'string' ? ( + + ) : ( + icon + )} + + {typeof balance === 'string' ? {balance} : balance} + {typeof description === 'string' ? ( + + {description} + + ) : ( + description + )} + + {!!end && {end}} + + ); +}); diff --git a/packages/mobile/src/stepper/Stepper.tsx b/packages/mobile/src/stepper/Stepper.tsx index 9eed77e131..d30dc886e7 100644 --- a/packages/mobile/src/stepper/Stepper.tsx +++ b/packages/mobile/src/stepper/Stepper.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { WithTimingConfig } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -274,209 +274,209 @@ type StepperComponent = = Record React.ReactElement; const StepperBase = memo( - forwardRef( - = Record>( - _props: StepperProps, - ref: React.Ref, - ) => { - const mergedProps = useComponentConfig('Stepper', _props); - const { - direction, - activeStepId, - steps, - complete, - style, - completedStepAccessibilityLabel = 'Complete', - styles, - gap = direction === 'vertical' ? undefined : horizontalStepGap, - accessibilityLabel: accessibilityLabelProp, - StepperStepComponent = direction === 'vertical' - ? (DefaultStepperStepVertical as StepperStepComponent) - : (DefaultStepperStepHorizontal as StepperStepComponent), - StepperSubstepContainerComponent = direction === 'vertical' - ? (DefaultStepperSubstepContainerVertical as StepperSubstepContainerComponent) - : (DefaultStepperSubstepContainerHorizontal as StepperSubstepContainerComponent), - // never show labels below the steps on mobile - StepperLabelComponent = direction === 'vertical' - ? (DefaultStepperLabelVertical as StepperLabelComponent) - : null, - StepperProgressComponent = direction === 'vertical' - ? (DefaultStepperProgressVertical as StepperProgressComponent) - : (DefaultStepperProgressHorizontal as StepperProgressComponent), - StepperIconComponent = direction === 'vertical' - ? (DefaultStepperIconVertical as StepperIconComponent) - : null, - StepperHeaderComponent = direction === 'vertical' - ? null - : (DefaultStepperHeaderHorizontal as StepperHeaderComponent), - progressTimingConfig = defaultProgressTimingConfig, - animate = true, - disableAnimateOnMount, - ...props - } = mergedProps; - const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); - - // Derive activeStep from activeStepId - const activeStep = useMemo(() => { - if (!activeStepId) return null; - return flattenSteps(steps).find((step) => step.id === activeStepId) || null; - }, [activeStepId, steps]); - - const [activeStepLabelElement, setActiveStepLabelElement] = useState(null); - - const activeFlatStepIndex = activeStep ? flatStepIds.indexOf(activeStep.id) : -1; - - const { rootStyle, stepStyles } = useMemo(() => { - const { root, ...stepStyles } = styles ?? {}; - const rootStyle = [style, root]; - return { rootStyle, stepStyles }; - }, [styles, style]); - - const accessibilityLabel = useMemo(() => { - if (accessibilityLabelProp) return accessibilityLabelProp; - if (!activeStep) return 'No active step'; - - const pagination = `${activeFlatStepIndex + 1} of ${flatStepIds.length}`; - const stepLabel = typeof activeStep.label === 'string' ? activeStep.label : null; - const baseLabel = - activeStep.accessibilityLabel ?? stepLabel ?? `Step ${activeFlatStepIndex + 1}`; - return `${baseLabel} ${pagination}`; - }, [activeStep, activeFlatStepIndex, flatStepIds.length, accessibilityLabelProp]); - - /* - Due to the possibility of null sub components, the root elements ends up being the best experience in certain cases. - Specifically, a horizontal stepper or a vertical stepper with no labels. - */ - const isRootAccessible = direction === 'horizontal' || StepperLabelComponent === null; - - const activeStepIndex = useMemo(() => { - return activeStepId - ? steps.findIndex( - (step) => - step.id === activeStepId || containsStep({ step, targetStepId: activeStepId }), - ) - : -1; - }, [activeStepId, steps]); - - // The effective cascade target: when complete, fill all steps up to the last one. - // Otherwise, fill up to activeStepIndex. - const cascadeTarget = complete ? steps.length - 1 : activeStepIndex; - - // Cascade animation state: advances one step at a time toward cascadeTarget. - // When disableAnimateOnMount is false (default), start unfilled (-1) so the - // cascade animates bars one-at-a-time up to the target on mount. - const [filledStepIndex, setFilledStepIndex] = useState(() => - disableAnimateOnMount ? cascadeTarget : -1, - ); - const targetStepIndexRef = useRef(cascadeTarget); - - useEffect(() => { - targetStepIndexRef.current = cascadeTarget; - - if (!animate) { - setFilledStepIndex(cascadeTarget); - return; - } - - // Advance one step immediately to kick off the cascade + = Record>({ + ref, + ..._props + }: StepperProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('Stepper', _props); + const { + direction, + activeStepId, + steps, + complete, + style, + completedStepAccessibilityLabel = 'Complete', + styles, + gap = direction === 'vertical' ? undefined : horizontalStepGap, + accessibilityLabel: accessibilityLabelProp, + StepperStepComponent = direction === 'vertical' + ? (DefaultStepperStepVertical as StepperStepComponent) + : (DefaultStepperStepHorizontal as StepperStepComponent), + StepperSubstepContainerComponent = direction === 'vertical' + ? (DefaultStepperSubstepContainerVertical as StepperSubstepContainerComponent) + : (DefaultStepperSubstepContainerHorizontal as StepperSubstepContainerComponent), + // never show labels below the steps on mobile + StepperLabelComponent = direction === 'vertical' + ? (DefaultStepperLabelVertical as StepperLabelComponent) + : null, + StepperProgressComponent = direction === 'vertical' + ? (DefaultStepperProgressVertical as StepperProgressComponent) + : (DefaultStepperProgressHorizontal as StepperProgressComponent), + StepperIconComponent = direction === 'vertical' + ? (DefaultStepperIconVertical as StepperIconComponent) + : null, + StepperHeaderComponent = direction === 'vertical' + ? null + : (DefaultStepperHeaderHorizontal as StepperHeaderComponent), + progressTimingConfig = defaultProgressTimingConfig, + animate = true, + disableAnimateOnMount, + ...props + } = mergedProps; + const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); + + // Derive activeStep from activeStepId + const activeStep = useMemo(() => { + if (!activeStepId) return null; + return flattenSteps(steps).find((step) => step.id === activeStepId) || null; + }, [activeStepId, steps]); + + const [activeStepLabelElement, setActiveStepLabelElement] = useState(null); + + const activeFlatStepIndex = activeStep ? flatStepIds.indexOf(activeStep.id) : -1; + + const { rootStyle, stepStyles } = useMemo(() => { + const { root, ...stepStyles } = styles ?? {}; + const rootStyle = [style, root]; + return { rootStyle, stepStyles }; + }, [styles, style]); + + const accessibilityLabel = useMemo(() => { + if (accessibilityLabelProp) return accessibilityLabelProp; + if (!activeStep) return 'No active step'; + + const pagination = `${activeFlatStepIndex + 1} of ${flatStepIds.length}`; + const stepLabel = typeof activeStep.label === 'string' ? activeStep.label : null; + const baseLabel = + activeStep.accessibilityLabel ?? stepLabel ?? `Step ${activeFlatStepIndex + 1}`; + return `${baseLabel} ${pagination}`; + }, [activeStep, activeFlatStepIndex, flatStepIds.length, accessibilityLabelProp]); + + /* + Due to the possibility of null sub components, the root elements ends up being the best experience in certain cases. + Specifically, a horizontal stepper or a vertical stepper with no labels. + */ + const isRootAccessible = direction === 'horizontal' || StepperLabelComponent === null; + + const activeStepIndex = useMemo(() => { + return activeStepId + ? steps.findIndex( + (step) => + step.id === activeStepId || containsStep({ step, targetStepId: activeStepId }), + ) + : -1; + }, [activeStepId, steps]); + + // The effective cascade target: when complete, fill all steps up to the last one. + // Otherwise, fill up to activeStepIndex. + const cascadeTarget = complete ? steps.length - 1 : activeStepIndex; + + // Cascade animation state: advances one step at a time toward cascadeTarget. + // When disableAnimateOnMount is false (default), start unfilled (-1) so the + // cascade animates bars one-at-a-time up to the target on mount. + const [filledStepIndex, setFilledStepIndex] = useState(() => + disableAnimateOnMount ? cascadeTarget : -1, + ); + const targetStepIndexRef = useRef(cascadeTarget); + + useEffect(() => { + targetStepIndexRef.current = cascadeTarget; + + if (!animate) { + setFilledStepIndex(cascadeTarget); + return; + } + + // Advance one step immediately to kick off the cascade + setFilledStepIndex((prev) => { + if (prev === cascadeTarget) return prev; + return prev < cascadeTarget ? prev + 1 : prev - 1; + }); + + // Continue advancing on a fixed interval for fluid, overlapping springs + const interval = setInterval(() => { setFilledStepIndex((prev) => { - if (prev === cascadeTarget) return prev; - return prev < cascadeTarget ? prev + 1 : prev - 1; + const target = targetStepIndexRef.current; + if (prev === target) return prev; + return prev < target ? prev + 1 : prev - 1; }); + }, cascadeStaggerMs); - // Continue advancing on a fixed interval for fluid, overlapping springs - const interval = setInterval(() => { - setFilledStepIndex((prev) => { - const target = targetStepIndexRef.current; - if (prev === target) return prev; - return prev < target ? prev + 1 : prev - 1; - }); - }, cascadeStaggerMs); - - return () => clearInterval(interval); - }, [cascadeTarget, animate]); - - // Compute progress for each step: 1 if filled, 0 if not - const getStepProgress = useCallback( - (index: number) => { - if (!animate) { - if (complete) return 1; - if (activeStepIndex < 0) return 0; - return index <= activeStepIndex ? 1 : 0; - } - if (filledStepIndex < 0) return 0; - return index <= filledStepIndex ? 1 : 0; - }, - [complete, animate, activeStepIndex, filledStepIndex], - ); - - return ( - - {StepperHeaderComponent && ( - - )} - - {steps.map((step, index) => { - const isDescendentActive = activeStepId - ? containsStep({ step, targetStepId: activeStepId }) - : false; - const RenderedStepComponent = step.Component ?? StepperStepComponent; - - if (!RenderedStepComponent) return null; - - return ( - - ); - })} - - - ); - }, - ), + return () => clearInterval(interval); + }, [cascadeTarget, animate]); + + // Compute progress for each step: 1 if filled, 0 if not + const getStepProgress = useCallback( + (index: number) => { + if (!animate) { + if (complete) return 1; + if (activeStepIndex < 0) return 0; + return index <= activeStepIndex ? 1 : 0; + } + if (filledStepIndex < 0) return 0; + return index <= filledStepIndex ? 1 : 0; + }, + [complete, animate, activeStepIndex, filledStepIndex], + ); + + return ( + + {StepperHeaderComponent && ( + + )} + + {steps.map((step, index) => { + const isDescendentActive = activeStepId + ? containsStep({ step, targetStepId: activeStepId }) + : false; + const RenderedStepComponent = step.Component ?? StepperStepComponent; + + if (!RenderedStepComponent) return null; + + return ( + + ); + })} + + + ); + }, ); export const Stepper = StepperBase as StepperComponent; diff --git a/packages/mobile/src/sticky-footer/StickyFooter.tsx b/packages/mobile/src/sticky-footer/StickyFooter.tsx index 3143f65faf..398c09bbd9 100644 --- a/packages/mobile/src/sticky-footer/StickyFooter.tsx +++ b/packages/mobile/src/sticky-footer/StickyFooter.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import { View } from 'react-native'; import { Box, type BoxProps } from '../layout'; @@ -17,38 +17,36 @@ export type StickyFooterProps = BoxProps & { }; export const StickyFooter = memo( - forwardRef( - ( - { - elevated, - elevation = elevated ? 1 : 0, - children, - testID = 'sticky-footer', - role = 'toolbar', - accessibilityLabel = 'footer', - compact, - paddingX = 3, - paddingTop = compact ? 2 : 3, - flexShrink = 0, - ...props - }: StickyFooterProps, - forwardedRef: React.ForwardedRef, - ) => { - return ( - - {children} - - ); - }, - ), + ({ + ref: forwardedRef, + elevated, + elevation = elevated ? 1 : 0, + children, + testID = 'sticky-footer', + role = 'toolbar', + accessibilityLabel = 'footer', + compact, + paddingX = 3, + paddingTop = compact ? 2 : 3, + flexShrink = 0, + ...props + }: StickyFooterProps & { + ref?: React.Ref; + }) => { + return ( + + {children} + + ); + }, ); diff --git a/packages/mobile/src/system/Pressable.tsx b/packages/mobile/src/system/Pressable.tsx index 743170e151..5e2c2a5b74 100644 --- a/packages/mobile/src/system/Pressable.tsx +++ b/packages/mobile/src/system/Pressable.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; import { type AccessibilityProps, type GestureResponderEvent, @@ -57,275 +57,277 @@ export type PressableBaseProps = AccessibilityProps & export type PressableProps = PressableBaseProps & NativePressableProps; -export const Pressable = memo( - forwardRef(function Pressable( - { - // Interactable - children, - disabled, - background, - block, - borderColor, - borderRadius, - borderWidth, - elevation, - contentStyle, - wrapperStyles, - blendStyles, - transparentWhileInactive, - transparentWhilePressed, - pin, - bordered, - borderedTop, - borderedBottom, - borderedStart, - borderedEnd, - borderedHorizontal, - borderedVertical, - dangerouslySetBackground, - display, - position, - overflow, - zIndex, - gap, - columnGap, - rowGap, - justifyContent, - alignContent, - alignItems, - alignSelf, - flexDirection, - flexWrap, - color, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderTopWidth, - borderEndWidth, - borderBottomWidth, - borderStartWidth, - font, - fontFamily, - fontSize, - fontWeight, - lineHeight, - textAlign, - textDecorationStyle, - textDecorationLine, - textTransform, - padding, - paddingX, - paddingY, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - margin, - marginX, - marginY, - marginTop, - marginBottom, - marginStart, - marginEnd, - userSelect, - width, - height, - minWidth, - minHeight, - maxWidth, - maxHeight, - aspectRatio, - top, - bottom, - left, - right, - transform, - flexBasis, - flexShrink, - flexGrow, - opacity, - // Pressable - disableDebounce, - feedback = 'none', - loading, - onPress, - onPressIn, - onPressOut, - noScaleOnPress, - style, - eventConfig, - analyticsId, - debounceTime, - testID, - ...props - }: PressableProps, - forwardedRef: React.ForwardedRef, - ) { - const [pressIn, pressOut, pressScale] = usePressAnimation(); - const [pressed, setPressed] = useState(false); - const lastPressedTimeStampRef = useRef(null); +export const Pressable = memo(function Pressable({ + ref: forwardedRef, - const onEventHandler = useEventHandler('Button', 'onPress', eventConfig, analyticsId); + // Interactable + children, - const onPressHandler = useCallback( - (event: GestureResponderEvent) => { - if (feedback === 'light') void Haptics.lightImpact(); - else if (feedback === 'normal') void Haptics.normalImpact(); - else if (feedback === 'heavy') void Haptics.heavyImpact(); - onPress?.(event); - onEventHandler(); - }, - [feedback, onEventHandler, onPress], - ); - const handlePress = useCallback( - (event: GestureResponderEvent) => { - const now = Date.now(); - if (disableDebounce || debounceTime === undefined) { - onPressHandler(event); - lastPressedTimeStampRef.current = now; - return; - } - if ( - lastPressedTimeStampRef.current === null || - now - lastPressedTimeStampRef.current >= debounceTime - ) { - onPressHandler(event); - } + disabled, + background, + block, + borderColor, + borderRadius, + borderWidth, + elevation, + contentStyle, + wrapperStyles, + blendStyles, + transparentWhileInactive, + transparentWhilePressed, + pin, + bordered, + borderedTop, + borderedBottom, + borderedStart, + borderedEnd, + borderedHorizontal, + borderedVertical, + dangerouslySetBackground, + display, + position, + overflow, + zIndex, + gap, + columnGap, + rowGap, + justifyContent, + alignContent, + alignItems, + alignSelf, + flexDirection, + flexWrap, + color, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopWidth, + borderEndWidth, + borderBottomWidth, + borderStartWidth, + font, + fontFamily, + fontSize, + fontWeight, + lineHeight, + textAlign, + textDecorationStyle, + textDecorationLine, + textTransform, + padding, + paddingX, + paddingY, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + margin, + marginX, + marginY, + marginTop, + marginBottom, + marginStart, + marginEnd, + userSelect, + width, + height, + minWidth, + minHeight, + maxWidth, + maxHeight, + aspectRatio, + top, + bottom, + left, + right, + transform, + flexBasis, + flexShrink, + flexGrow, + opacity, + + // Pressable + disableDebounce, + + feedback = 'none', + loading, + onPress, + onPressIn, + onPressOut, + noScaleOnPress, + style, + eventConfig, + analyticsId, + debounceTime, + testID, + ...props +}: PressableProps & { + ref?: React.Ref; +}) { + const [pressIn, pressOut, pressScale] = usePressAnimation(); + const [pressed, setPressed] = useState(false); + const lastPressedTimeStampRef = useRef(null); + + const onEventHandler = useEventHandler('Button', 'onPress', eventConfig, analyticsId); + + const onPressHandler = useCallback( + (event: GestureResponderEvent) => { + if (feedback === 'light') void Haptics.lightImpact(); + else if (feedback === 'normal') void Haptics.normalImpact(); + else if (feedback === 'heavy') void Haptics.heavyImpact(); + onPress?.(event); + onEventHandler(); + }, + [feedback, onEventHandler, onPress], + ); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + const now = Date.now(); + if (disableDebounce || debounceTime === undefined) { + onPressHandler(event); lastPressedTimeStampRef.current = now; - }, - [debounceTime, disableDebounce, onPressHandler], - ); + return; + } + if ( + lastPressedTimeStampRef.current === null || + now - lastPressedTimeStampRef.current >= debounceTime + ) { + onPressHandler(event); + } + lastPressedTimeStampRef.current = now; + }, + [debounceTime, disableDebounce, onPressHandler], + ); - const handlePressIn = useCallback( - (event: GestureResponderEvent) => { - setPressed(true); - pressIn(event); - onPressIn?.(event); - }, - [pressIn, onPressIn], - ); + const handlePressIn = useCallback( + (event: GestureResponderEvent) => { + setPressed(true); + pressIn(event); + onPressIn?.(event); + }, + [pressIn, onPressIn], + ); - const handlePressOut = useCallback( - (event: GestureResponderEvent) => { - setPressed(false); - pressOut(event); - onPressOut?.(event); - }, - [pressOut, onPressOut], - ); + const handlePressOut = useCallback( + (event: GestureResponderEvent) => { + setPressed(false); + pressOut(event); + onPressOut?.(event); + }, + [pressOut, onPressOut], + ); - const accessibilityState = useMemo( - () => ({ busy: loading, disabled: !!disabled }), - [loading, disabled], - ); + const accessibilityState = useMemo( + () => ({ busy: loading, disabled: !!disabled }), + [loading, disabled], + ); - const scaleOnPressStyle = useMemo(() => [{ transform: [{ scale: pressScale }] }], [pressScale]); + const scaleOnPressStyle = useMemo(() => [{ transform: [{ scale: pressScale }] }], [pressScale]); - return ( - )} + return ( + )} + > + - - {children} - - - ); - }), -); + {children} + + + ); +}); diff --git a/packages/mobile/src/tabs/DefaultTab.tsx b/packages/mobile/src/tabs/DefaultTab.tsx index aeedf2259b..6ed77360e3 100644 --- a/packages/mobile/src/tabs/DefaultTab.tsx +++ b/packages/mobile/src/tabs/DefaultTab.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { type GestureResponderEvent, Pressable, @@ -38,66 +38,64 @@ type DefaultTabComponent = ( ) => React.ReactElement; const DefaultTabComponent = memo( - forwardRef( - ( - { - id, - label, - disabled: disabledProp, - onPress, - count, - max, - accessibilityLabel, - style, - testID, - color = 'fg', - activeColor = 'fgPrimary', - ...props - }: DefaultTabProps, - ref: React.ForwardedRef, - ) => { - const theme = useTheme(); - const { - activeTab, - updateActiveTab, - disabled: allTabsDisabled, - } = useTabsContext & DefaultTabLabelProps>(); - const isActive = activeTab?.id === id; - const isDisabled = disabledProp || allTabsDisabled; + ({ + ref, + id, + label, + disabled: disabledProp, + onPress, + count, + max, + accessibilityLabel, + style, + testID, + color = 'fg', + activeColor = 'fgPrimary', + ...props + }: DefaultTabProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + const { + activeTab, + updateActiveTab, + disabled: allTabsDisabled, + } = useTabsContext & DefaultTabLabelProps>(); + const isActive = activeTab?.id === id; + const isDisabled = disabledProp || allTabsDisabled; - const handlePress = useCallback( - (event: GestureResponderEvent) => { - updateActiveTab(id); - onPress?.(id, event); - }, - [id, onPress, updateActiveTab], - ); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + updateActiveTab(id); + onPress?.(id, event); + }, + [id, onPress, updateActiveTab], + ); - return ( - - - - {label} - - {!!count && } - - - ); - }, - ), + return ( + + + + {label} + + {!!count && } + + + ); + }, ); DefaultTabComponent.displayName = 'DefaultTab'; diff --git a/packages/mobile/src/tabs/SegmentedTab.tsx b/packages/mobile/src/tabs/SegmentedTab.tsx index 28eb264643..e0dd4addbd 100644 --- a/packages/mobile/src/tabs/SegmentedTab.tsx +++ b/packages/mobile/src/tabs/SegmentedTab.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { type GestureResponderEvent, type StyleProp, @@ -48,100 +48,100 @@ type SegmentedTabFC = ( ) => React.ReactElement; const SegmentedTabComponent = memo( - forwardRef( - ( - _props: SegmentedTabProps, - ref: React.ForwardedRef, - ) => { - const mergedProps = useComponentConfig('SegmentedTab', _props); - const { - id, - label, - disabled: disabledProp, - onPress, - color = 'fg', - activeColor = 'fgInverse', - style, - 'aria-selected': ariaSelected, - accessibilityRole = 'button', - testID, - font = 'headline', - fontFamily, - fontSize, - fontWeight, - lineHeight, - ...props - } = mergedProps; - const { activeTab, updateActiveTab, disabled: allTabsDisabled } = useTabsContext(); - const isActive = activeTab?.id === id; - const isDisabled = disabledProp || allTabsDisabled; + ({ + ref, + ..._props + }: SegmentedTabProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('SegmentedTab', _props); + const { + id, + label, + disabled: disabledProp, + onPress, + color = 'fg', + activeColor = 'fgInverse', + style, + 'aria-selected': ariaSelected, + accessibilityRole = 'button', + testID, + font = 'headline', + fontFamily, + fontSize, + fontWeight, + lineHeight, + ...props + } = mergedProps; + const { activeTab, updateActiveTab, disabled: allTabsDisabled } = useTabsContext(); + const isActive = activeTab?.id === id; + const isDisabled = disabledProp || allTabsDisabled; - const handlePress = useCallback( - (event: GestureResponderEvent) => { - updateActiveTab(id); - onPress?.(id, event); - }, - [id, onPress, updateActiveTab], - ); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + updateActiveTab(id); + onPress?.(id, event); + }, + [id, onPress, updateActiveTab], + ); - const theme = useTheme(); - const activeColorRgbaString = theme.color[activeColor]; - const inactiveColorRgbaString = theme.color[color]; - const animatedColor = useSharedValue( - isActive ? activeColorRgbaString : inactiveColorRgbaString, - ); + const theme = useTheme(); + const activeColorRgbaString = theme.color[activeColor]; + const inactiveColorRgbaString = theme.color[color]; + const animatedColor = useSharedValue( + isActive ? activeColorRgbaString : inactiveColorRgbaString, + ); - animatedColor.value = withSpring( - isActive ? activeColorRgbaString : inactiveColorRgbaString, - tabsSpringConfig, - ); + animatedColor.value = withSpring( + isActive ? activeColorRgbaString : inactiveColorRgbaString, + tabsSpringConfig, + ); - const animatedTextStyles = useAnimatedStyle( - () => ({ color: animatedColor.value }), - [animatedColor], - ); + const animatedTextStyles = useAnimatedStyle( + () => ({ color: animatedColor.value }), + [animatedColor], + ); - const pressableStyle = useMemo( - () => ({ - borderRadius: theme.borderRadius[1000], - opacity: disabledProp && !allTabsDisabled ? accessibleOpacityDisabled : undefined, - }), - [theme.borderRadius, disabledProp, allTabsDisabled], - ); + const pressableStyle = useMemo( + () => ({ + borderRadius: theme.borderRadius[1000], + opacity: disabledProp && !allTabsDisabled ? accessibleOpacityDisabled : undefined, + }), + [theme.borderRadius, disabledProp, allTabsDisabled], + ); - return ( - - - {typeof label === 'string' ? ( - - {label} - - ) : ( - label - )} - - - ); - }, - ), + return ( + + + {typeof label === 'string' ? ( + + {label} + + ) : ( + label + )} + + + ); + }, ); SegmentedTabComponent.displayName = 'SegmentedTab'; diff --git a/packages/mobile/src/tabs/SegmentedTabs.tsx b/packages/mobile/src/tabs/SegmentedTabs.tsx index a3de5d084c..0202bd86c8 100644 --- a/packages/mobile/src/tabs/SegmentedTabs.tsx +++ b/packages/mobile/src/tabs/SegmentedTabs.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import { useComponentConfig } from '../hooks/useComponentConfig'; @@ -34,30 +34,33 @@ type SegmentedTabsFC = ( ) => React.ReactElement; const SegmentedTabsComponent = memo( - forwardRef( - (_props: SegmentedTabsProps, ref: React.ForwardedRef) => { - const mergedProps = useComponentConfig('SegmentedTabs', _props); - const { - TabComponent = SegmentedTab, - TabsActiveIndicatorComponent = SegmentedTabsActiveIndicator, - activeBackground = 'bgInverse', - background = 'bgSecondary', - borderRadius = 700, - ...props - } = mergedProps; - return ( - - ); - }, - ), + ({ + ref, + ..._props + }: SegmentedTabsProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('SegmentedTabs', _props); + const { + TabComponent = SegmentedTab, + TabsActiveIndicatorComponent = SegmentedTabsActiveIndicator, + activeBackground = 'bgInverse', + background = 'bgSecondary', + borderRadius = 700, + ...props + } = mergedProps; + return ( + + ); + }, ); SegmentedTabsComponent.displayName = 'SegmentedTabs'; diff --git a/packages/mobile/src/tabs/TabIndicator.tsx b/packages/mobile/src/tabs/TabIndicator.tsx index 8ea545eec7..9e939c72eb 100644 --- a/packages/mobile/src/tabs/TabIndicator.tsx +++ b/packages/mobile/src/tabs/TabIndicator.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import { Animated } from 'react-native'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -22,35 +22,39 @@ export type TabIndicatorProps = SharedProps & { /** @deprecated Use DefaultTabsActiveIndicator instead. This will be removed in a future major release. */ /** @deprecationExpectedRemoval v10 */ export const TabIndicator = memo( - forwardRef( - ( - { width, x, background = 'bg', testID, ...props }: TabIndicatorProps, - forwardedRef: React.ForwardedRef, - ) => { - const { widthStyle, xStyle } = useTabIndicatorStyles({ width, x }); + ({ + ref: forwardedRef, + width, + x, + background = 'bg', + testID, + ...props + }: TabIndicatorProps & { + ref?: React.Ref; + }) => { + const { widthStyle, xStyle } = useTabIndicatorStyles({ width, x }); - return ( - + return ( + + - - - - ); - }, - ), + style={widthStyle} + testID="cds-tab-indicator-inner-bar" + width="100%" + /> + + + ); + }, ); TabIndicator.displayName = 'TabIndicator'; diff --git a/packages/mobile/src/tabs/TabNavigation.tsx b/packages/mobile/src/tabs/TabNavigation.tsx index 5c680058cf..315e726a8d 100644 --- a/packages/mobile/src/tabs/TabNavigation.tsx +++ b/packages/mobile/src/tabs/TabNavigation.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView, View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; @@ -112,158 +112,154 @@ type TabNavigationFC = ( ) => React.ReactElement; const TabNavigationComponent = memo( - forwardRef( - ( - { - tabs, - value = tabs[0].id, - variant = 'primary', - testID = 'tabNavigation', - background = 'bg', - onChange, - Component, - gap = 4, - role = 'tablist', - ...props - }, - forwardedRef, - ) => { - const isPrimary = useMemo(() => variant === 'primary', [variant]); - const [activeTabLayout, setActiveTabLayout] = useState({ width: 0, x: 0, y: 0, height: 0 }); - const { - scrollRef, - isScrollContentOverflowing, - isScrollContentOffscreenRight, - handleScroll, - handleScrollContainerLayout, - handleScrollContentSizeChange, - getPressableLayoutHandler, - } = useHorizontallyScrollingPressables(value, { - setActivePressableLayout: setActiveTabLayout, - }); + ({ + ref: forwardedRef, + tabs, + value = tabs[0].id, + variant = 'primary', + testID = 'tabNavigation', + background = 'bg', + onChange, + Component, + gap = 4, + role = 'tablist', + ...props + }: TabNavigationProps & { + ref?: React.Ref; + }) => { + const isPrimary = useMemo(() => variant === 'primary', [variant]); + const [activeTabLayout, setActiveTabLayout] = useState({ width: 0, x: 0, y: 0, height: 0 }); + const { + scrollRef, + isScrollContentOverflowing, + isScrollContentOffscreenRight, + handleScroll, + handleScrollContainerLayout, + handleScrollContentSizeChange, + getPressableLayoutHandler, + } = useHorizontallyScrollingPressables(value, { + setActivePressableLayout: setActiveTabLayout, + }); - // TO DO: The `tab` role is not being announced correctly because of this RN issue https://github.com/facebook/react-native/issues/43266 - const descendantAriaRole = role === 'tablist' ? 'tab' : 'radio'; + // TO DO: The `tab` role is not being announced correctly because of this RN issue https://github.com/facebook/react-native/issues/43266 + const descendantAriaRole = role === 'tablist' ? 'tab' : 'radio'; - const getTabPressHandler = useCallback( - ({ id, onPress }: Pick) => { - return function handleTabPress() { - onChange(id); - onPress?.(id); // handle callback - }; - }, - [onChange], - ); + const getTabPressHandler = useCallback( + ({ id, onPress }: Pick) => { + return function handleTabPress() { + onChange(id); + onPress?.(id); // handle callback + }; + }, + [onChange], + ); - useEffect(() => { - if (isDevelopment() && variant === 'secondary') { - console.warn( - 'Deprecation Warning: Secondary tabs are deprecated, please migrate to primary tabs. In the case of nested tabs, consider using TabbedChips', - ); - } - }, [variant]); + useEffect(() => { + if (isDevelopment() && variant === 'secondary') { + console.warn( + 'Deprecation Warning: Secondary tabs are deprecated, please migrate to primary tabs. In the case of nested tabs, consider using TabbedChips', + ); + } + }, [variant]); - // Iterate over the tabs and create Pressable TabLabels - const tabLabels = useMemo( - () => - tabs - .filter(Boolean) - .map( - ({ - id, - onPress, - label, - disabled, - accessibilityLabel = label, - count, - max, - testID: tabLabelTestID = `${testID}-tabLabel--${id}`, - Component: TabComponent, - }) => { - const isActiveTab = id === value; - const a11yLabelToString = - typeof accessibilityLabel === 'string' ? accessibilityLabel : undefined; - const a11yState = - role === 'radiogroup' ? { checked: isActiveTab } : { selected: isActiveTab }; + // Iterate over the tabs and create Pressable TabLabels + const tabLabels = useMemo( + () => + tabs + .filter(Boolean) + .map( + ({ + id, + onPress, + label, + disabled, + accessibilityLabel = label, + count, + max, + testID: tabLabelTestID = `${testID}-tabLabel--${id}`, + Component: TabComponent, + }) => { + const isActiveTab = id === value; + const a11yLabelToString = + typeof accessibilityLabel === 'string' ? accessibilityLabel : undefined; + const a11yState = + role === 'radiogroup' ? { checked: isActiveTab } : { selected: isActiveTab }; - const CustomTabComponent = TabComponent ?? Component; + const CustomTabComponent = TabComponent ?? Component; - return ( - - - {CustomTabComponent ? ( - - ) : ( - - {label} - - )} - - - ); - }, - ), - [ - tabs, - testID, - value, - role, - Component, - getPressableLayoutHandler, - descendantAriaRole, - getTabPressHandler, - variant, - ], - ); + return ( + + + {CustomTabComponent ? ( + + ) : ( + + {label} + + )} + + + ); + }, + ), + [ + tabs, + testID, + value, + role, + Component, + getPressableLayoutHandler, + descendantAriaRole, + getTabPressHandler, + variant, + ], + ); - return ( - + - - - {tabLabels} - {isPrimary && ( - - )} - - - {isScrollContentOverflowing && isScrollContentOffscreenRight ? ( - - ) : null} - - ); - }, - ), + + {tabLabels} + {isPrimary && ( + + )} + + + {isScrollContentOverflowing && isScrollContentOffscreenRight ? : null} + + ); + }, ); /** diff --git a/packages/mobile/src/tabs/Tabs.tsx b/packages/mobile/src/tabs/Tabs.tsx index b5453581cc..47fce6f0ed 100644 --- a/packages/mobile/src/tabs/Tabs.tsx +++ b/packages/mobile/src/tabs/Tabs.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import React, { memo, useCallback, useImperativeHandle, useRef, useState } from 'react'; import { type StyleProp, View, type ViewStyle } from 'react-native'; import Animated, { useAnimatedStyle, @@ -116,120 +116,120 @@ type TabsFC = = Tab ) => React.ReactElement; const TabsComponent = memo( - forwardRef( - = TabValue>( - _props: TabsProps, - ref: React.ForwardedRef, - ) => { - const mergedProps = useComponentConfig('Tabs', _props); - const { - tabs, - TabComponent = DefaultTab, - TabsActiveIndicatorComponent = DefaultTabsActiveIndicator, - activeBackground, - color, - activeColor, - activeTab, - disabled, - onChange, - styles, - style, - role = 'tablist', - position = 'relative', - alignSelf = 'flex-start', - opacity, - onActiveTabElementChange, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - testID, - ...props - } = mergedProps; - const tabsContainerRef = useRef(null); - useImperativeHandle(ref, () => tabsContainerRef.current as View, []); // merge internal ref to forwarded ref + = TabValue>({ + ref, + ..._props + }: TabsProps & { + ref?: React.Ref; + }) => { + const mergedProps = useComponentConfig('Tabs', _props); + const { + tabs, + TabComponent = DefaultTab, + TabsActiveIndicatorComponent = DefaultTabsActiveIndicator, + activeBackground, + color, + activeColor, + activeTab, + disabled, + onChange, + styles, + style, + role = 'tablist', + position = 'relative', + alignSelf = 'flex-start', + opacity, + onActiveTabElementChange, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + testID, + ...props + } = mergedProps; + const tabsContainerRef = useRef(null); + useImperativeHandle(ref, () => tabsContainerRef.current as View, []); // merge internal ref to forwarded ref - const refMap = useRefMap(); - const api = useTabs({ tabs, activeTab, disabled, onChange }); + const refMap = useRefMap(); + const api = useTabs({ tabs, activeTab, disabled, onChange }); - const [activeTabRect, setActiveTabRect] = useState(defaultRect); - const previousActiveRef = useRef(activeTab); + const [activeTabRect, setActiveTabRect] = useState(defaultRect); + const previousActiveRef = useRef(activeTab); - const updateActiveTabRect = useCallback(() => { - const activeTabRef = activeTab ? refMap.getRef(activeTab.id) : null; - if (!activeTabRef || !tabsContainerRef.current) return; - activeTabRef.measureLayout(tabsContainerRef.current, (x, y, width, height) => - setActiveTabRect({ x, y, width, height }), - ); - }, [activeTab, refMap]); - - const registerRef = useCallback( - (tabId: string, ref: View) => { - refMap.registerRef(tabId, ref); - if (activeTab?.id === tabId) { - onActiveTabElementChange?.(ref); - } - }, - [activeTab, onActiveTabElementChange, refMap], + const updateActiveTabRect = useCallback(() => { + const activeTabRef = activeTab ? refMap.getRef(activeTab.id) : null; + if (!activeTabRef || !tabsContainerRef.current) return; + activeTabRef.measureLayout(tabsContainerRef.current, (x, y, width, height) => + setActiveTabRect({ x, y, width, height }), ); + }, [activeTab, refMap]); - if (previousActiveRef.current !== activeTab) { - previousActiveRef.current = activeTab; - updateActiveTabRect(); - } + const registerRef = useCallback( + (tabId: string, ref: View) => { + refMap.registerRef(tabId, ref); + if (activeTab?.id === tabId) { + onActiveTabElementChange?.(ref); + } + }, + [activeTab, onActiveTabElementChange, refMap], + ); - return ( - - }> - - {tabs.map((tabProps) => { - const { id, Component: CustomTabComponent, ...tabRest } = tabProps; - const RenderedTab = CustomTabComponent ?? TabComponent; - const renderedTabProps = { - activeColor, - color, - id, - style: styles?.tab, - ...tabRest, - }; - return ( - - - - ); - })} - - - ); - }, - ), + if (previousActiveRef.current !== activeTab) { + previousActiveRef.current = activeTab; + updateActiveTabRect(); + } + + return ( + + }> + + {tabs.map((tabProps) => { + const { id, Component: CustomTabComponent, ...tabRest } = tabProps; + const RenderedTab = CustomTabComponent ?? TabComponent; + const renderedTabProps = { + activeColor, + color, + id, + style: styles?.tab, + ...tabRest, + }; + return ( + + + + ); + })} + + + ); + }, ); TabsComponent.displayName = 'Tabs'; diff --git a/packages/mobile/src/tag/Tag.tsx b/packages/mobile/src/tag/Tag.tsx index bbff84a634..5df4c79cfe 100644 --- a/packages/mobile/src/tag/Tag.tsx +++ b/packages/mobile/src/tag/Tag.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { @@ -66,7 +66,12 @@ export type TagProps = TagBaseProps & Omit; export const Tag = memo( - forwardRef((_props: TagProps, forwardedRef: React.ForwardedRef) => { + ({ + ref: forwardedRef, + ..._props + }: TagProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Tag', _props); const { children, @@ -141,5 +146,5 @@ export const Tag = memo( ) : null} ); - }), + }, ); diff --git a/packages/mobile/src/tour/DefaultTourStepArrow.tsx b/packages/mobile/src/tour/DefaultTourStepArrow.tsx index c330dc9dac..fe17e42ee4 100644 --- a/packages/mobile/src/tour/DefaultTourStepArrow.tsx +++ b/packages/mobile/src/tour/DefaultTourStepArrow.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import { Box } from '../layout/Box'; @@ -6,7 +6,14 @@ import { Box } from '../layout/Box'; import type { TourStepArrowComponentProps } from './Tour'; export const DefaultTourStepArrow = memo( - forwardRef(({ placement, arrow, style }, ref) => { + ({ + ref, + placement, + arrow, + style, + }: TourStepArrowComponentProps & { + ref?: React.Ref; + }) => { const width = 24; const height = 24; const hideArrow = (arrow?.centerOffset ?? 0) > 0; @@ -36,5 +43,5 @@ export const DefaultTourStepArrow = memo( width={24} /> ); - }), + }, ); diff --git a/packages/mobile/src/tour/Tour.tsx b/packages/mobile/src/tour/Tour.tsx index e0b8bd3809..74699742d4 100644 --- a/packages/mobile/src/tour/Tour.tsx +++ b/packages/mobile/src/tour/Tour.tsx @@ -47,7 +47,7 @@ export type TourStepArrowComponentProps = { }; // ------------ SUBCOMPONENT TYPES ------------ -export type TourStepArrowComponent = React.ForwardRefExoticComponent< +export type TourStepArrowComponent = React.FC< TourStepArrowComponentProps & { ref?: React.Ref } >; diff --git a/packages/mobile/src/typography/Text.tsx b/packages/mobile/src/typography/Text.tsx index 59d92595a4..d64288fc29 100644 --- a/packages/mobile/src/typography/Text.tsx +++ b/packages/mobile/src/typography/Text.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated, type StyleProp, @@ -99,23 +99,208 @@ const styles = StyleSheet.create({ const HEADER_FONTS = new Set(['display1', 'display2', 'display3', 'title1', 'title2']); export const Text = memo( - forwardRef( - ( - { - children, - style, - animated, + ({ + ref, + children, + style, + animated, + disabled, + mono, + underline, + tabularNumbers, + numberOfLines, + ellipsize, + noWrap, + testID, + dangerouslySetColor, + dangerouslySetBackground, + + // Begin style props + display, + + position, + overflow, + zIndex, + gap, + columnGap, + rowGap, + justifyContent, + alignContent, + alignItems, + alignSelf, + flexDirection, + flexWrap, + color = 'fg', + background, + borderColor, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopWidth, + borderEndWidth, + borderBottomWidth, + borderStartWidth, + elevation, + borderWidth, + borderRadius, + font = 'inherit', + fontFamily = font, + fontSize = font, + fontWeight = font, + lineHeight = font, + align = 'start', + textDecorationStyle, + textDecorationLine, + textTransform, + padding, + paddingX, + paddingY, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + margin, + marginX, + marginY, + marginTop, + marginBottom, + marginStart, + marginEnd, + userSelect, + width, + height, + minWidth, + minHeight, + maxWidth, + maxHeight, + aspectRatio, + top, + bottom, + left, + right, + transform, + flexBasis, + flexShrink, + flexGrow, + opacity, + renderEmptyNode = true, + accessibilityRole = HEADER_FONTS.has(font) ? 'header' : undefined, + ...props + }: TextProps & { + ref?: React.Ref; + }) => { + const Component = animated ? Animated.Text : NativeText; + + const theme = useTheme(); + const textAlign = useTextAlign(align); + const monoFontFamily = mono && fontFamily !== 'inherit' && theme.fontFamilyMono?.[fontFamily]; + const textTransformValue = + textTransform ?? + (fontFamily !== 'inherit' + ? theme.textTransform[fontFamily as keyof typeof theme.textTransform] + : undefined); + const computedNumberOfLines = + noWrap || (ellipsize && typeof numberOfLines === 'undefined') ? 1 : numberOfLines; + + const propStyles = useMemo( + () => [ + disabled && styles.disabled, + underline && styles.underline, + tabularNumbers && styles.tabularNumbers, + ellipsize && styles.ellipsize, + monoFontFamily ? { fontFamily: monoFontFamily } : undefined, + dangerouslySetColor ? { color: dangerouslySetColor } : undefined, + dangerouslySetBackground ? { backgroundColor: dangerouslySetBackground } : undefined, + ], + [ disabled, - mono, underline, tabularNumbers, - numberOfLines, ellipsize, - noWrap, - testID, + monoFontFamily, dangerouslySetColor, dangerouslySetBackground, - // Begin style props + ], + ); + + const memoizedStyles = useMemo( + () => [ + getStyles( + { + display, + position, + overflow, + zIndex, + gap, + columnGap, + rowGap, + justifyContent, + alignContent, + alignItems, + alignSelf, + flexDirection, + flexWrap, + color, + background, + borderColor, + borderWidth, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + borderTopWidth, + borderEndWidth, + borderBottomWidth, + borderStartWidth, + elevation, + fontFamily, + fontSize, + fontWeight, + lineHeight, + textDecorationStyle, + textDecorationLine, + textTransform: textTransformValue, + padding, + paddingX, + paddingY, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + margin, + marginX, + marginY, + marginTop, + marginBottom, + marginStart, + marginEnd, + userSelect, + width, + height, + minWidth, + minHeight, + maxWidth, + maxHeight, + aspectRatio, + top, + bottom, + left, + right, + transform, + flexBasis, + flexShrink, + flexGrow, + opacity, + ...textAlign, + }, + theme, + ), + propStyles, + style, + ], + [ display, position, overflow, @@ -129,9 +314,11 @@ export const Text = memo( alignSelf, flexDirection, flexWrap, - color = 'fg', + color, background, borderColor, + borderWidth, + borderRadius, borderTopLeftRadius, borderTopRightRadius, borderBottomLeftRadius, @@ -141,17 +328,13 @@ export const Text = memo( borderBottomWidth, borderStartWidth, elevation, - borderWidth, - borderRadius, - font = 'inherit', - fontFamily = font, - fontSize = font, - fontWeight = font, - lineHeight = font, - align = 'start', + fontFamily, + fontSize, + fontWeight, + lineHeight, textDecorationStyle, textDecorationLine, - textTransform, + textTransformValue, padding, paddingX, paddingY, @@ -183,217 +366,34 @@ export const Text = memo( flexShrink, flexGrow, opacity, - renderEmptyNode = true, - accessibilityRole = HEADER_FONTS.has(font) ? 'header' : undefined, - ...props - }, - ref, - ) => { - const Component = animated ? Animated.Text : NativeText; - - const theme = useTheme(); - const textAlign = useTextAlign(align); - const monoFontFamily = mono && fontFamily !== 'inherit' && theme.fontFamilyMono?.[fontFamily]; - const textTransformValue = - textTransform ?? - (fontFamily !== 'inherit' - ? theme.textTransform[fontFamily as keyof typeof theme.textTransform] - : undefined); - const computedNumberOfLines = - noWrap || (ellipsize && typeof numberOfLines === 'undefined') ? 1 : numberOfLines; - - const propStyles = useMemo( - () => [ - disabled && styles.disabled, - underline && styles.underline, - tabularNumbers && styles.tabularNumbers, - ellipsize && styles.ellipsize, - monoFontFamily ? { fontFamily: monoFontFamily } : undefined, - dangerouslySetColor ? { color: dangerouslySetColor } : undefined, - dangerouslySetBackground ? { backgroundColor: dangerouslySetBackground } : undefined, - ], - [ - disabled, - underline, - tabularNumbers, - ellipsize, - monoFontFamily, - dangerouslySetColor, - dangerouslySetBackground, - ], - ); - - const memoizedStyles = useMemo( - () => [ - getStyles( - { - display, - position, - overflow, - zIndex, - gap, - columnGap, - rowGap, - justifyContent, - alignContent, - alignItems, - alignSelf, - flexDirection, - flexWrap, - color, - background, - borderColor, - borderWidth, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderTopWidth, - borderEndWidth, - borderBottomWidth, - borderStartWidth, - elevation, - fontFamily, - fontSize, - fontWeight, - lineHeight, - textDecorationStyle, - textDecorationLine, - textTransform: textTransformValue, - padding, - paddingX, - paddingY, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - margin, - marginX, - marginY, - marginTop, - marginBottom, - marginStart, - marginEnd, - userSelect, - width, - height, - minWidth, - minHeight, - maxWidth, - maxHeight, - aspectRatio, - top, - bottom, - left, - right, - transform, - flexBasis, - flexShrink, - flexGrow, - opacity, - ...textAlign, - }, - theme, - ), - propStyles, - style, - ], - [ - display, - position, - overflow, - zIndex, - gap, - columnGap, - rowGap, - justifyContent, - alignContent, - alignItems, - alignSelf, - flexDirection, - flexWrap, - color, - background, - borderColor, - borderWidth, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - borderTopWidth, - borderEndWidth, - borderBottomWidth, - borderStartWidth, - elevation, - fontFamily, - fontSize, - fontWeight, - lineHeight, - textDecorationStyle, - textDecorationLine, - textTransformValue, - padding, - paddingX, - paddingY, - paddingTop, - paddingBottom, - paddingStart, - paddingEnd, - margin, - marginX, - marginY, - marginTop, - marginBottom, - marginStart, - marginEnd, - userSelect, - width, - height, - minWidth, - minHeight, - maxWidth, - maxHeight, - aspectRatio, - top, - bottom, - left, - right, - transform, - flexBasis, - flexShrink, - flexGrow, - opacity, - textAlign, - theme, - propStyles, - style, - ], - ); + textAlign, + theme, + propStyles, + style, + ], + ); - if ( - !renderEmptyNode && - (children === null || children === undefined || children === '' || Number.isNaN(children)) - ) - return null; + if ( + !renderEmptyNode && + (children === null || children === undefined || children === '' || Number.isNaN(children)) + ) + return null; - return ( - } - testID={testID} - {...props} - > - {children} - - ); - }, - ), + return ( + } + testID={testID} + {...props} + > + {children} + + ); + }, ); Text.displayName = 'Text'; diff --git a/packages/mobile/src/typography/TextBody.tsx b/packages/mobile/src/typography/TextBody.tsx index 88784b044f..10ae020c0b 100644 --- a/packages/mobile/src/typography/TextBody.tsx +++ b/packages/mobile/src/typography/TextBody.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextBodyProps = TextProps; * @deprecationExpectedRemoval v10 */ export const TextBody = memo( - forwardRef(({ font = 'body', ...props }, ref) => ( - - )), + ({ + ref, + font = 'body', + ...props + }: TextBodyProps & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextCaption.tsx b/packages/mobile/src/typography/TextCaption.tsx index 45ecf00653..245c28a6c8 100644 --- a/packages/mobile/src/typography/TextCaption.tsx +++ b/packages/mobile/src/typography/TextCaption.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextCaptionProps = TextProps; * @deprecationExpectedRemoval v10 */ export const TextCaption = memo( - forwardRef(({ font = 'caption', ...props }, ref) => ( - - )), + ({ + ref, + font = 'caption', + ...props + }: TextCaptionProps & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextDisplay1.tsx b/packages/mobile/src/typography/TextDisplay1.tsx index 2e7faca203..8ec911ce52 100644 --- a/packages/mobile/src/typography/TextDisplay1.tsx +++ b/packages/mobile/src/typography/TextDisplay1.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,9 +20,12 @@ export type TextDisplay1Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextDisplay1 = memo( - forwardRef( - ({ accessibilityRole = 'header', font = 'display1', ...props }, ref) => ( - - ), - ), + ({ + ref, + accessibilityRole = 'header', + font = 'display1', + ...props + }: TextDisplay1Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextDisplay2.tsx b/packages/mobile/src/typography/TextDisplay2.tsx index fa3cc37e56..7a4c37d0fc 100644 --- a/packages/mobile/src/typography/TextDisplay2.tsx +++ b/packages/mobile/src/typography/TextDisplay2.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,9 +20,12 @@ export type TextDisplay2Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextDisplay2 = memo( - forwardRef( - ({ accessibilityRole = 'header', font = 'display2', ...props }, ref) => ( - - ), - ), + ({ + ref, + accessibilityRole = 'header', + font = 'display2', + ...props + }: TextDisplay2Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextDisplay3.tsx b/packages/mobile/src/typography/TextDisplay3.tsx index 76a353e08f..a2c9784d5c 100644 --- a/packages/mobile/src/typography/TextDisplay3.tsx +++ b/packages/mobile/src/typography/TextDisplay3.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,9 +20,12 @@ export type TextDisplay3Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextDisplay3 = memo( - forwardRef( - ({ accessibilityRole = 'header', font = 'display3', ...props }, ref) => ( - - ), - ), + ({ + ref, + accessibilityRole = 'header', + font = 'display3', + ...props + }: TextDisplay3Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextHeadline.tsx b/packages/mobile/src/typography/TextHeadline.tsx index d6adaf333f..12972511fc 100644 --- a/packages/mobile/src/typography/TextHeadline.tsx +++ b/packages/mobile/src/typography/TextHeadline.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextHeadlineProps = TextProps; * @deprecationExpectedRemoval v10 */ export const TextHeadline = memo( - forwardRef(({ font = 'headline', ...props }, ref) => ( - - )), + ({ + ref, + font = 'headline', + ...props + }: TextHeadlineProps & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextInherited.tsx b/packages/mobile/src/typography/TextInherited.tsx index 4caf1a5457..1e7e4dc754 100644 --- a/packages/mobile/src/typography/TextInherited.tsx +++ b/packages/mobile/src/typography/TextInherited.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextInheritedProps = TextProps; * @deprecationExpectedRemoval v10 */ export const TextInherited = memo( - forwardRef(({ font = 'inherit', ...props }, ref) => ( - - )), + ({ + ref, + font = 'inherit', + ...props + }: TextInheritedProps & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextLabel1.tsx b/packages/mobile/src/typography/TextLabel1.tsx index 1109425446..e96909a6b5 100644 --- a/packages/mobile/src/typography/TextLabel1.tsx +++ b/packages/mobile/src/typography/TextLabel1.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextLabel1Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextLabel1 = memo( - forwardRef(({ font = 'label1', ...props }, ref) => ( - - )), + ({ + ref, + font = 'label1', + ...props + }: TextLabel1Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextLabel2.tsx b/packages/mobile/src/typography/TextLabel2.tsx index 8ef01ac486..3899f7fb7a 100644 --- a/packages/mobile/src/typography/TextLabel2.tsx +++ b/packages/mobile/src/typography/TextLabel2.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextLabel2Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextLabel2 = memo( - forwardRef(({ font = 'label2', ...props }, ref) => ( - - )), + ({ + ref, + font = 'label2', + ...props + }: TextLabel2Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextLegal.tsx b/packages/mobile/src/typography/TextLegal.tsx index 8abd316515..fce3770bc4 100644 --- a/packages/mobile/src/typography/TextLegal.tsx +++ b/packages/mobile/src/typography/TextLegal.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextLegalProps = TextProps; * @deprecationExpectedRemoval v10 */ export const TextLegal = memo( - forwardRef(({ font = 'legal', ...props }, ref) => ( - - )), + ({ + ref, + font = 'legal', + ...props + }: TextLegalProps & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextTitle1.tsx b/packages/mobile/src/typography/TextTitle1.tsx index e47a91ead1..f363365c2f 100644 --- a/packages/mobile/src/typography/TextTitle1.tsx +++ b/packages/mobile/src/typography/TextTitle1.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,9 +20,12 @@ export type TextTitle1Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextTitle1 = memo( - forwardRef( - ({ accessibilityRole = 'header', font = 'title1', ...props }, ref) => ( - - ), - ), + ({ + ref, + accessibilityRole = 'header', + font = 'title1', + ...props + }: TextTitle1Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextTitle2.tsx b/packages/mobile/src/typography/TextTitle2.tsx index b57b96b7ce..6ae1b94dd9 100644 --- a/packages/mobile/src/typography/TextTitle2.tsx +++ b/packages/mobile/src/typography/TextTitle2.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,9 +20,12 @@ export type TextTitle2Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextTitle2 = memo( - forwardRef( - ({ accessibilityRole = 'header', font = 'title2', ...props }, ref) => ( - - ), - ), + ({ + ref, + accessibilityRole = 'header', + font = 'title2', + ...props + }: TextTitle2Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextTitle3.tsx b/packages/mobile/src/typography/TextTitle3.tsx index 9348ee8639..890172ad61 100644 --- a/packages/mobile/src/typography/TextTitle3.tsx +++ b/packages/mobile/src/typography/TextTitle3.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextTitle3Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextTitle3 = memo( - forwardRef(({ font = 'title3', ...props }, ref) => ( - - )), + ({ + ref, + font = 'title3', + ...props + }: TextTitle3Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/typography/TextTitle4.tsx b/packages/mobile/src/typography/TextTitle4.tsx index 53dfbe80d9..04b06199d3 100644 --- a/packages/mobile/src/typography/TextTitle4.tsx +++ b/packages/mobile/src/typography/TextTitle4.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -20,7 +20,11 @@ export type TextTitle4Props = TextProps; * @deprecationExpectedRemoval v10 */ export const TextTitle4 = memo( - forwardRef(({ font = 'title4', ...props }, ref) => ( - - )), + ({ + ref, + font = 'title4', + ...props + }: TextTitle4Props & { + ref?: React.Ref; + }) => , ); diff --git a/packages/mobile/src/visualizations/ProgressBar.tsx b/packages/mobile/src/visualizations/ProgressBar.tsx index 307164eadf..344dadf8e6 100644 --- a/packages/mobile/src/visualizations/ProgressBar.tsx +++ b/packages/mobile/src/visualizations/ProgressBar.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, I18nManager, @@ -58,7 +58,12 @@ export type ProgressBarProps = ProgressBaseProps & { }; export const ProgressBar = memo( - forwardRef((_props: ProgressBarProps, forwardedRef: React.ForwardedRef) => { + ({ + ref: forwardedRef, + ..._props + }: ProgressBarProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('ProgressBar', _props); const { weight = 'normal', @@ -166,5 +171,5 @@ export const ProgressBar = memo( /> ); - }), + }, ); diff --git a/packages/mobile/src/visualizations/ProgressCircle.tsx b/packages/mobile/src/visualizations/ProgressCircle.tsx index 68e85a7689..cdd1442887 100644 --- a/packages/mobile/src/visualizations/ProgressCircle.tsx +++ b/packages/mobile/src/visualizations/ProgressCircle.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useEffect, useMemo, useRef } from 'react'; +import React, { memo, useEffect, useMemo, useRef } from 'react'; import { Animated, type StyleProp, StyleSheet, type View, type ViewStyle } from 'react-native'; import type { CircleProps } from 'react-native-svg'; import { Circle, G, Svg } from 'react-native-svg'; @@ -151,7 +151,12 @@ const ProgressCircleInner = memo( ); export const ProgressCircle = memo( - forwardRef((_props: ProgressCircleProps, forwardedRef: React.ForwardedRef) => { + ({ + ref: forwardedRef, + ..._props + }: ProgressCircleProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('ProgressCircle', _props); const { indeterminate, @@ -300,7 +305,7 @@ export const ProgressCircle = memo( }} ); - }), + }, ); const styleSheet = StyleSheet.create({ diff --git a/packages/mobile/src/visualizations/ProgressIndicator.tsx b/packages/mobile/src/visualizations/ProgressIndicator.tsx index 096c01e27d..e726ff5186 100644 --- a/packages/mobile/src/visualizations/ProgressIndicator.tsx +++ b/packages/mobile/src/visualizations/ProgressIndicator.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated, StyleSheet } from 'react-native'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; @@ -19,51 +19,54 @@ export type ProgressIndicatorProps = { } & BoxProps & SharedProps; -export const ProgressIndicator = memo( - forwardRef(function ProgressIndicator( - { progress, style, testID, ...boxProps }, - ref, - ) { - const theme = useTheme(); - const outerStyles = useMemo( - () => [styles.dash, { backgroundColor: theme.color.bgLine }], - [theme.color.bgLine], - ); - const innerStyles = useMemo( - () => [ - styles.dashOverlay, - { backgroundColor: theme.color.bgInverse, zIndex: 2 }, - progress && { - transform: [ - { - translateX: progress.interpolate({ - inputRange: [0, 1], - outputRange: [-PROGRESS_INDICATOR_WIDTH, 0], - }), - }, - ], - }, - ], - [theme.color.bgInverse, progress], - ); +export const ProgressIndicator = memo(function ProgressIndicator({ + ref, + progress, + style, + testID, + ...boxProps +}: ProgressIndicatorProps & { + ref?: React.Ref; +}) { + const theme = useTheme(); + const outerStyles = useMemo( + () => [styles.dash, { backgroundColor: theme.color.bgLine }], + [theme.color.bgLine], + ); + const innerStyles = useMemo( + () => [ + styles.dashOverlay, + { backgroundColor: theme.color.bgInverse, zIndex: 2 }, + progress && { + transform: [ + { + translateX: progress.interpolate({ + inputRange: [0, 1], + outputRange: [-PROGRESS_INDICATOR_WIDTH, 0], + }), + }, + ], + }, + ], + [theme.color.bgInverse, progress], + ); - return ( - - - - - - ); - }), -); + return ( + + + + + + ); +}); const styles = StyleSheet.create({ dash: { diff --git a/packages/mobile/src/visualizations/chart/CartesianChart.tsx b/packages/mobile/src/visualizations/chart/CartesianChart.tsx index 1db17476b5..78b9329feb 100644 --- a/packages/mobile/src/visualizations/chart/CartesianChart.tsx +++ b/packages/mobile/src/visualizations/chart/CartesianChart.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type LayoutChangeEvent, type StyleProp, type View, type ViewStyle } from 'react-native'; import type { Rect } from '@coinbase/cds-common/types'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; @@ -195,467 +195,451 @@ export type CartesianChartProps = CartesianChartBaseProps & }; export const CartesianChart = memo( - forwardRef( - ( - { - series, - children, - layout = 'vertical', - animate = true, - enableScrubbing, - getScrubberAccessibilityLabel, - scrubberAccessibilityLabelStep, - xAxis: xAxisConfigProp, - yAxis: yAxisConfigProp, - inset, - onScrubberPositionChange, - legend, - legendPosition = 'bottom', - legendAccessibilityLabel, - width = '100%', - height = '100%', - style, - styles, - allowOverflowGestures, - fontFamilies, - fontProvider: fontProviderProp, - // React Native will collapse views by default when only used - // to group children, which interferes with gesture-handler - // https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture-detector/#:~:text=%7B%0A%20%20return%20%3C-,View,-collapsable%3D%7B - collapsable = false, - accessible = true, - accessibilityLabel, - accessibilityLiveRegion = 'polite', - ...props - }, - ref, - ) => { - const [containerLayout, onContainerLayout] = useChartLayout(); - - const chartWidth = containerLayout.width; - const chartHeight = containerLayout.height; - - const calculatedInset = useMemo( - () => - getChartInset( - inset, - layout === 'horizontal' - ? defaultHorizontalLayoutChartInset - : defaultVerticalLayoutChartInset, - ), - [inset, layout], - ); + ({ + ref, + series, + children, + layout = 'vertical', + animate = true, + enableScrubbing, + getScrubberAccessibilityLabel, + scrubberAccessibilityLabelStep, + xAxis: xAxisConfigProp, + yAxis: yAxisConfigProp, + inset, + onScrubberPositionChange, + legend, + legendPosition = 'bottom', + legendAccessibilityLabel, + width = '100%', + height = '100%', + style, + styles, + allowOverflowGestures, + fontFamilies, + fontProvider: fontProviderProp, - const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp), [xAxisConfigProp]); - const yAxisConfig = useMemo(() => getAxisConfig('y', yAxisConfigProp), [yAxisConfigProp]); + // React Native will collapse views by default when only used + // to group children, which interferes with gesture-handler + // https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture-detector/#:~:text=%7B%0A%20%20return%20%3C-,View,-collapsable%3D%7B + collapsable = false, - // Horizontal layout supports multiple value axes on x, but only a single category axis on y. - // Vertical layout keeps a single x-axis to preserve existing behavior. - if (layout === 'horizontal' && yAxisConfig.length > 1) { - throw new Error( - 'When layout="horizontal", only one y-axis is supported. See https://cds.coinbase.com/components/charts/CartesianChart.', - ); - } + accessible = true, + accessibilityLabel, + accessibilityLiveRegion = 'polite', + ...props + }: CartesianChartProps & { + ref?: React.Ref; + }) => { + const [containerLayout, onContainerLayout] = useChartLayout(); + + const chartWidth = containerLayout.width; + const chartHeight = containerLayout.height; + + const calculatedInset = useMemo( + () => + getChartInset( + inset, + layout === 'horizontal' + ? defaultHorizontalLayoutChartInset + : defaultVerticalLayoutChartInset, + ), + [inset, layout], + ); - if (layout !== 'horizontal' && xAxisConfig.length > 1) { - throw new Error( - 'Multiple x-axes are only supported when layout="horizontal". See https://cds.coinbase.com/components/charts/CartesianChart.', - ); - } + const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp), [xAxisConfigProp]); + const yAxisConfig = useMemo(() => getAxisConfig('y', yAxisConfigProp), [yAxisConfigProp]); - const { renderedAxes, registerAxis, unregisterAxis, axisPadding } = useTotalAxisPadding(); + // Horizontal layout supports multiple value axes on x, but only a single category axis on y. + // Vertical layout keeps a single x-axis to preserve existing behavior. + if (layout === 'horizontal' && yAxisConfig.length > 1) { + throw new Error( + 'When layout="horizontal", only one y-axis is supported. See https://cds.coinbase.com/components/charts/CartesianChart.', + ); + } - const totalInset = useMemo( - () => ({ - top: calculatedInset.top + axisPadding.top, - right: calculatedInset.right + axisPadding.right, - bottom: calculatedInset.bottom + axisPadding.bottom, - left: calculatedInset.left + axisPadding.left, - }), - [calculatedInset, axisPadding], + if (layout !== 'horizontal' && xAxisConfig.length > 1) { + throw new Error( + 'Multiple x-axes are only supported when layout="horizontal". See https://cds.coinbase.com/components/charts/CartesianChart.', ); + } + + const { renderedAxes, registerAxis, unregisterAxis, axisPadding } = useTotalAxisPadding(); + + const totalInset = useMemo( + () => ({ + top: calculatedInset.top + axisPadding.top, + right: calculatedInset.right + axisPadding.right, + bottom: calculatedInset.bottom + axisPadding.bottom, + left: calculatedInset.left + axisPadding.left, + }), + [calculatedInset, axisPadding], + ); - const chartRect: Rect = useMemo(() => { - if (chartWidth <= 0 || chartHeight <= 0) return { x: 0, y: 0, width: 0, height: 0 }; + const chartRect: Rect = useMemo(() => { + if (chartWidth <= 0 || chartHeight <= 0) return { x: 0, y: 0, width: 0, height: 0 }; - const availableWidth = chartWidth - totalInset.left - totalInset.right; - const availableHeight = chartHeight - totalInset.top - totalInset.bottom; + const availableWidth = chartWidth - totalInset.left - totalInset.right; + const availableHeight = chartHeight - totalInset.top - totalInset.bottom; - return { - x: totalInset.left, - y: totalInset.top, - width: availableWidth > 0 ? availableWidth : 0, - height: availableHeight > 0 ? availableHeight : 0, - }; - }, [chartHeight, chartWidth, totalInset]); - - const { xAxes, xScales } = useMemo(() => { - const axes = new Map(); - const scales = new Map(); - if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) - return { xAxes: axes, xScales: scales }; - - xAxisConfig.forEach((axisParam) => { - const axisId = axisParam.id ?? defaultAxisId; - - // Get relevant series data. - const relevantSeries = - xAxisConfig.length > 1 - ? (series?.filter((s) => (s.xAxisId ?? defaultAxisId) === axisId) ?? []) - : (series ?? []); - - // Calculate domain and range. - const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'x', layout); - const range = getAxisRange(axisParam, chartRect, 'x'); - - const axisConfig: CartesianAxisConfig = { - scaleType: axisParam.scaleType, - domain: dataDomain, - range, - data: axisParam.data, - categoryPadding: axisParam.categoryPadding, - domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'nice' : 'strict'), - baseline: axisParam.baseline, - }; + return { + x: totalInset.left, + y: totalInset.top, + width: availableWidth > 0 ? availableWidth : 0, + height: availableHeight > 0 ? availableHeight : 0, + }; + }, [chartHeight, chartWidth, totalInset]); - // Create the scale. - const scale = getCartesianAxisScale({ - config: axisConfig, - type: 'x', - range: axisConfig.range, - dataDomain: axisConfig.domain, - layout, - }); + const { xAxes, xScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); + if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) + return { xAxes: axes, xScales: scales }; - if (scale) { - scales.set(axisId, scale); - - // Update axis config with actual scale domain (after .nice() or other adjustments). - const scaleDomain = scale.domain(); - const actualDomain = - Array.isArray(scaleDomain) && scaleDomain.length === 2 - ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } - : axisConfig.domain; - - axes.set(axisId, { - ...axisConfig, - domain: actualDomain, - }); - } - }); + xAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; + + // Get relevant series data. + const relevantSeries = + xAxisConfig.length > 1 + ? (series?.filter((s) => (s.xAxisId ?? defaultAxisId) === axisId) ?? []) + : (series ?? []); + + // Calculate domain and range. + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'x', layout); + const range = getAxisRange(axisParam, chartRect, 'x'); + + const axisConfig: CartesianAxisConfig = { + scaleType: axisParam.scaleType, + domain: dataDomain, + range, + data: axisParam.data, + categoryPadding: axisParam.categoryPadding, + domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'nice' : 'strict'), + baseline: axisParam.baseline, + }; - return { xAxes: axes, xScales: scales }; - }, [xAxisConfig, series, chartRect, layout]); - - // We need a set of serialized scales usable in UI thread. - const xSerializableScales = useMemo(() => { - const serializableScales = new Map(); - xScales.forEach((scale, id) => { - const serializableScale = convertToSerializableScale(scale); - if (serializableScale) { - serializableScales.set(id, serializableScale); - } + // Create the scale. + const scale = getCartesianAxisScale({ + config: axisConfig, + type: 'x', + range: axisConfig.range, + dataDomain: axisConfig.domain, + layout, }); - return serializableScales; - }, [xScales]); - - const { yAxes, yScales } = useMemo(() => { - const axes = new Map(); - const scales = new Map(); - if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) - return { yAxes: axes, yScales: scales }; - - yAxisConfig.forEach((axisParam) => { - const axisId = axisParam.id ?? defaultAxisId; - - // Get relevant series data. - const relevantSeries = - yAxisConfig.length > 1 - ? (series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []) - : (series ?? []); - - // Calculate domain and range. - const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'y', layout); - const range = getAxisRange(axisParam, chartRect, 'y'); - - const axisConfig: CartesianAxisConfig = { - scaleType: axisParam.scaleType, - domain: dataDomain, - range, - data: axisParam.data, - categoryPadding: axisParam.categoryPadding, - domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'strict' : 'nice'), - baseline: axisParam.baseline, - }; - // Create the scale. - const scale = getCartesianAxisScale({ - config: axisConfig, - type: 'y', - range: axisConfig.range, - dataDomain: axisConfig.domain, - layout, - }); + if (scale) { + scales.set(axisId, scale); - if (scale) { - scales.set(axisId, scale); - - // Update axis config with actual scale domain (after .nice() or other adjustments). - const scaleDomain = scale.domain(); - const actualDomain = - Array.isArray(scaleDomain) && scaleDomain.length === 2 - ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } - : axisConfig.domain; - - axes.set(axisId, { - ...axisConfig, - domain: actualDomain, - }); - } - }); + // Update axis config with actual scale domain (after .nice() or other adjustments). + const scaleDomain = scale.domain(); + const actualDomain = + Array.isArray(scaleDomain) && scaleDomain.length === 2 + ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } + : axisConfig.domain; + axes.set(axisId, { + ...axisConfig, + domain: actualDomain, + }); + } + }); + + return { xAxes: axes, xScales: scales }; + }, [xAxisConfig, series, chartRect, layout]); + + // We need a set of serialized scales usable in UI thread. + const xSerializableScales = useMemo(() => { + const serializableScales = new Map(); + xScales.forEach((scale, id) => { + const serializableScale = convertToSerializableScale(scale); + if (serializableScale) { + serializableScales.set(id, serializableScale); + } + }); + return serializableScales; + }, [xScales]); + + const { yAxes, yScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); + if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) return { yAxes: axes, yScales: scales }; - }, [yAxisConfig, series, chartRect, layout]); - - // We need a set of serialized scales usable in UI thread - const ySerializableScales = useMemo(() => { - const serializableScales = new Map(); - yScales.forEach((scale, id) => { - const serializableScale = convertToSerializableScale(scale); - if (serializableScale) { - serializableScales.set(id, serializableScale); - } - }); - return serializableScales; - }, [yScales]); - - const getXAxis = useCallback((id?: string) => xAxes.get(id ?? defaultAxisId), [xAxes]); - const getYAxis = useCallback((id?: string) => yAxes.get(id ?? defaultAxisId), [yAxes]); - const getXScale = useCallback((id?: string) => xScales.get(id ?? defaultAxisId), [xScales]); - const getYScale = useCallback((id?: string) => yScales.get(id ?? defaultAxisId), [yScales]); - const getXSerializableScale = useCallback( - (id?: string) => xSerializableScales.get(id ?? defaultAxisId), - [xSerializableScales], - ); - const getYSerializableScale = useCallback( - (id?: string) => ySerializableScales.get(id ?? defaultAxisId), - [ySerializableScales], - ); - const getSeries = useCallback( - (seriesId?: string) => series?.find((s) => s.id === seriesId), - [series], - ); - const stackedDataMap = useMemo(() => { - if (!series) return new Map>(); - return calculateStackedSeriesData(series, layout, xAxisConfig, yAxisConfig); - }, [series, layout, xAxisConfig, yAxisConfig]); - - const getStackedSeriesData = useCallback( - (seriesId?: string) => { - if (!seriesId) return undefined; - return stackedDataMap.get(seriesId); - }, - [stackedDataMap], - ); + yAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; + + // Get relevant series data. + const relevantSeries = + yAxisConfig.length > 1 + ? (series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []) + : (series ?? []); + + // Calculate domain and range. + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'y', layout); + const range = getAxisRange(axisParam, chartRect, 'y'); + + const axisConfig: CartesianAxisConfig = { + scaleType: axisParam.scaleType, + domain: dataDomain, + range, + data: axisParam.data, + categoryPadding: axisParam.categoryPadding, + domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'strict' : 'nice'), + baseline: axisParam.baseline, + }; - const categoryAxisIsX = useMemo(() => { - return layout !== 'horizontal'; - }, [layout]); + // Create the scale. + const scale = getCartesianAxisScale({ + config: axisConfig, + type: 'y', + range: axisConfig.range, + dataDomain: axisConfig.domain, + layout, + }); - const categoryAxisConfig = useMemo(() => { - return categoryAxisIsX - ? (xAxisConfig[0] ?? yAxisConfig[0]) - : (yAxisConfig[0] ?? xAxisConfig[0]); - }, [categoryAxisIsX, xAxisConfig, yAxisConfig]); + if (scale) { + scales.set(axisId, scale); - const dataLength = useMemo(() => { - // If category axis has categorical data, use that length. - if (categoryAxisConfig.data && categoryAxisConfig.data.length > 0) { - return categoryAxisConfig.data.length; - } + // Update axis config with actual scale domain (after .nice() or other adjustments). + const scaleDomain = scale.domain(); + const actualDomain = + Array.isArray(scaleDomain) && scaleDomain.length === 2 + ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } + : axisConfig.domain; - // Otherwise, find the longest series. - if (!series || series.length === 0) return 0; - return series.reduce((max, s) => { - const seriesData = getStackedSeriesData(s.id); - return Math.max(max, seriesData?.length ?? 0); - }, 0); - }, [categoryAxisConfig, series, getStackedSeriesData]); - - const getAxisBounds = useCallback( - (axisId: string): Rect | undefined => { - const axis = renderedAxes.get(axisId); - if (!axis || !chartRect) return; - - const axesAtPosition = Array.from(renderedAxes.values()) - .filter((a) => a.position === axis.position) - .sort((a, b) => a.id.localeCompare(b.id)); - - const axisIndex = axesAtPosition.findIndex((a) => a.id === axisId); - if (axisIndex === -1) return; - - // Calculate offset from previous axes at the same position - const offsetFromPreviousAxes = axesAtPosition - .slice(0, axisIndex) - .reduce((sum, a) => sum + a.size, 0); - - if (axis.position === 'top') { - // Position above the chart rect, accounting for user inset - const startY = calculatedInset.top + offsetFromPreviousAxes; - return { - x: chartRect.x, - y: startY, - width: chartRect.width, - height: axis.size, - }; - } else if (axis.position === 'bottom') { - // Position below the chart rect, accounting for user inset - const startY = chartRect.y + chartRect.height + offsetFromPreviousAxes; - return { - x: chartRect.x, - y: startY, - width: chartRect.width, - height: axis.size, - }; - } else if (axis.position === 'left') { - // Position to the left of the chart rect, accounting for user inset - const startX = calculatedInset.left + offsetFromPreviousAxes; - return { - x: startX, - y: chartRect.y, - width: axis.size, - height: chartRect.height, - }; - } else { - // right - position to the right of the chart rect, accounting for user inset - const startX = chartRect.x + chartRect.width + offsetFromPreviousAxes; - return { - x: startX, - y: chartRect.y, - width: axis.size, - height: chartRect.height, - }; - } - }, - [renderedAxes, chartRect, calculatedInset], - ); + axes.set(axisId, { + ...axisConfig, + domain: actualDomain, + }); + } + }); + + return { yAxes: axes, yScales: scales }; + }, [yAxisConfig, series, chartRect, layout]); + + // We need a set of serialized scales usable in UI thread + const ySerializableScales = useMemo(() => { + const serializableScales = new Map(); + yScales.forEach((scale, id) => { + const serializableScale = convertToSerializableScale(scale); + if (serializableScale) { + serializableScales.set(id, serializableScale); + } + }); + return serializableScales; + }, [yScales]); + + const getXAxis = useCallback((id?: string) => xAxes.get(id ?? defaultAxisId), [xAxes]); + const getYAxis = useCallback((id?: string) => yAxes.get(id ?? defaultAxisId), [yAxes]); + const getXScale = useCallback((id?: string) => xScales.get(id ?? defaultAxisId), [xScales]); + const getYScale = useCallback((id?: string) => yScales.get(id ?? defaultAxisId), [yScales]); + const getXSerializableScale = useCallback( + (id?: string) => xSerializableScales.get(id ?? defaultAxisId), + [xSerializableScales], + ); + const getYSerializableScale = useCallback( + (id?: string) => ySerializableScales.get(id ?? defaultAxisId), + [ySerializableScales], + ); + const getSeries = useCallback( + (seriesId?: string) => series?.find((s) => s.id === seriesId), + [series], + ); - const fontProvider = useMemo(() => { - if (fontProviderProp) return fontProviderProp; - return Skia.TypefaceFontProvider.Make(); - }, [fontProviderProp]); + const stackedDataMap = useMemo(() => { + if (!series) return new Map>(); + return calculateStackedSeriesData(series, layout, xAxisConfig, yAxisConfig); + }, [series, layout, xAxisConfig, yAxisConfig]); - const contextValue: CartesianChartContextValue = useMemo( - () => ({ - layout, - series: series ?? [], - getSeries, - getSeriesData: getStackedSeriesData, - animate, - width: chartWidth, - height: chartHeight, - fontFamilies, - fontProvider, - getXAxis, - getYAxis, - getXScale, - getYScale, - getXSerializableScale, - getYSerializableScale, - drawingArea: chartRect, - dataLength, - registerAxis, - unregisterAxis, - getAxisBounds, - }), - [ - layout, - series, - getSeries, - getStackedSeriesData, - animate, - chartWidth, - chartHeight, - fontFamilies, - fontProvider, - getXAxis, - getYAxis, - getXScale, - getYScale, - getXSerializableScale, - getYSerializableScale, - chartRect, - dataLength, - registerAxis, - unregisterAxis, - getAxisBounds, - ], - ); + const getStackedSeriesData = useCallback( + (seriesId?: string) => { + if (!seriesId) return undefined; + return stackedDataMap.get(seriesId); + }, + [stackedDataMap], + ); - const rootStyles = useMemo(() => { - return [style, styles?.root]; - }, [style, styles?.root]); + const categoryAxisIsX = useMemo(() => { + return layout !== 'horizontal'; + }, [layout]); - const legendElement = useMemo(() => { - if (!legend) return; + const categoryAxisConfig = useMemo(() => { + return categoryAxisIsX + ? (xAxisConfig[0] ?? yAxisConfig[0]) + : (yAxisConfig[0] ?? xAxisConfig[0]); + }, [categoryAxisIsX, xAxisConfig, yAxisConfig]); - if (legend === true) { - const isHorizontal = legendPosition === 'top' || legendPosition === 'bottom'; - const flexDirection = isHorizontal ? 'row' : 'column'; + const dataLength = useMemo(() => { + // If category axis has categorical data, use that length. + if (categoryAxisConfig.data && categoryAxisConfig.data.length > 0) { + return categoryAxisConfig.data.length; + } - return ( - - ); + // Otherwise, find the longest series. + if (!series || series.length === 0) return 0; + return series.reduce((max, s) => { + const seriesData = getStackedSeriesData(s.id); + return Math.max(max, seriesData?.length ?? 0); + }, 0); + }, [categoryAxisConfig, series, getStackedSeriesData]); + + const getAxisBounds = useCallback( + (axisId: string): Rect | undefined => { + const axis = renderedAxes.get(axisId); + if (!axis || !chartRect) return; + + const axesAtPosition = Array.from(renderedAxes.values()) + .filter((a) => a.position === axis.position) + .sort((a, b) => a.id.localeCompare(b.id)); + + const axisIndex = axesAtPosition.findIndex((a) => a.id === axisId); + if (axisIndex === -1) return; + + // Calculate offset from previous axes at the same position + const offsetFromPreviousAxes = axesAtPosition + .slice(0, axisIndex) + .reduce((sum, a) => sum + a.size, 0); + + if (axis.position === 'top') { + // Position above the chart rect, accounting for user inset + const startY = calculatedInset.top + offsetFromPreviousAxes; + return { + x: chartRect.x, + y: startY, + width: chartRect.width, + height: axis.size, + }; + } else if (axis.position === 'bottom') { + // Position below the chart rect, accounting for user inset + const startY = chartRect.y + chartRect.height + offsetFromPreviousAxes; + return { + x: chartRect.x, + y: startY, + width: chartRect.width, + height: axis.size, + }; + } else if (axis.position === 'left') { + // Position to the left of the chart rect, accounting for user inset + const startX = calculatedInset.left + offsetFromPreviousAxes; + return { + x: startX, + y: chartRect.y, + width: axis.size, + height: chartRect.height, + }; + } else { + // right - position to the right of the chart rect, accounting for user inset + const startX = chartRect.x + chartRect.width + offsetFromPreviousAxes; + return { + x: startX, + y: chartRect.y, + width: axis.size, + height: chartRect.height, + }; } + }, + [renderedAxes, chartRect, calculatedInset], + ); - return legend; - }, [legend, legendAccessibilityLabel, legendPosition]); - - const rootBoxProps: BoxProps = useMemo( - () => ({ - ref, - height, - style: rootStyles, - width, - ...props, - }), - [ref, height, rootStyles, width, props], - ); + const fontProvider = useMemo(() => { + if (fontProviderProp) return fontProviderProp; + return Skia.TypefaceFontProvider.Make(); + }, [fontProviderProp]); + + const contextValue: CartesianChartContextValue = useMemo( + () => ({ + layout, + series: series ?? [], + getSeries, + getSeriesData: getStackedSeriesData, + animate, + width: chartWidth, + height: chartHeight, + fontFamilies, + fontProvider, + getXAxis, + getYAxis, + getXScale, + getYScale, + getXSerializableScale, + getYSerializableScale, + drawingArea: chartRect, + dataLength, + registerAxis, + unregisterAxis, + getAxisBounds, + }), + [ + layout, + series, + getSeries, + getStackedSeriesData, + animate, + chartWidth, + chartHeight, + fontFamilies, + fontProvider, + getXAxis, + getYAxis, + getXScale, + getYScale, + getXSerializableScale, + getYSerializableScale, + chartRect, + dataLength, + registerAxis, + unregisterAxis, + getAxisBounds, + ], + ); - return ( - - - {legend ? ( - - {(legendPosition === 'top' || legendPosition === 'left') && legendElement} - - - {children} - - - - {(legendPosition === 'bottom' || legendPosition === 'right') && legendElement} - - ) : ( - + const rootStyles = useMemo(() => { + return [style, styles?.root]; + }, [style, styles?.root]); + + const legendElement = useMemo(() => { + if (!legend) return; + + if (legend === true) { + const isHorizontal = legendPosition === 'top' || legendPosition === 'bottom'; + const flexDirection = isHorizontal ? 'row' : 'column'; + + return ( + + ); + } + + return legend; + }, [legend, legendAccessibilityLabel, legendPosition]); + + const rootBoxProps: BoxProps = useMemo( + () => ({ + ref, + height, + style: rootStyles, + width, + ...props, + }), + [ref, height, rootStyles, width, props], + ); + + return ( + + + {legend ? ( + + {(legendPosition === 'top' || legendPosition === 'left') && legendElement} + - )} - - - ); - }, - ), + {(legendPosition === 'bottom' || legendPosition === 'right') && legendElement} + + ) : ( + + + {children} + + + + )} + + + ); + }, ); diff --git a/packages/mobile/src/visualizations/chart/PeriodSelector.tsx b/packages/mobile/src/visualizations/chart/PeriodSelector.tsx index 07bf334d08..ea19411116 100644 --- a/packages/mobile/src/visualizations/chart/PeriodSelector.tsx +++ b/packages/mobile/src/visualizations/chart/PeriodSelector.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { StyleSheet, View, type ViewStyle } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; @@ -98,40 +98,51 @@ const styles = StyleSheet.create({ }); export const LiveTabLabel = memo( - forwardRef( - ({ color = 'fgNegative', label = 'LIVE', font = 'label1', hideDot, style, ...props }, ref) => { - const theme = useTheme(); - - const colorKey = color as keyof typeof theme.color; - const textColor = theme.color[colorKey] || color; - - const dotStyle = useMemo( - () => ({ - width: theme.space[1], - height: theme.space[1], - borderRadius: 1000, - marginRight: theme.space[0.75], - backgroundColor: textColor, - }), - [theme.space, textColor], - ); - - return ( - - {!hideDot && } - - {label} - - - ); - }, - ), + ({ + ref, + color = 'fgNegative', + label = 'LIVE', + font = 'label1', + hideDot, + style, + ...props + }: LiveTabLabelProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + + const colorKey = color as keyof typeof theme.color; + const textColor = theme.color[colorKey] || color; + + const dotStyle = useMemo( + () => ({ + width: theme.space[1], + height: theme.space[1], + borderRadius: 1000, + marginRight: theme.space[0.75], + backgroundColor: textColor, + }), + [theme.space, textColor], + ); + + return ( + + {!hideDot && } + + {label} + + + ); + }, ); const PeriodSelectorTab: TabComponent = memo( - forwardRef((props: SegmentedTabProps, ref: React.ForwardedRef) => ( - - )), + ({ + ref, + ...props + }: SegmentedTabProps & { + ref?: React.Ref; + }) => , ); export type PeriodSelectorProps = SegmentedTabsProps; @@ -141,31 +152,29 @@ export type PeriodSelectorProps = SegmentedTabsProps; * It provides transparent background, primary wash active state, and full-width layout by default. */ export const PeriodSelector = memo( - forwardRef( - ( - { - background = 'transparent', - activeBackground = 'bgPrimaryWash', - activeColor = 'fgPrimary', - width = '100%', - justifyContent = 'space-between', - TabComponent = PeriodSelectorTab, - TabsActiveIndicatorComponent = PeriodSelectorActiveIndicator, - ...props - }: PeriodSelectorProps, - ref: React.ForwardedRef, - ) => ( - - ), + ({ + ref, + background = 'transparent', + activeBackground = 'bgPrimaryWash', + activeColor = 'fgPrimary', + width = '100%', + justifyContent = 'space-between', + TabComponent = PeriodSelectorTab, + TabsActiveIndicatorComponent = PeriodSelectorActiveIndicator, + ...props + }: PeriodSelectorProps & { + ref?: React.Ref; + }) => ( + ), ); diff --git a/packages/mobile/src/visualizations/chart/__stories__/ChartAccessibility.stories.tsx b/packages/mobile/src/visualizations/chart/__stories__/ChartAccessibility.stories.tsx index 924f94d4c8..8489c5a5a3 100644 --- a/packages/mobile/src/visualizations/chart/__stories__/ChartAccessibility.stories.tsx +++ b/packages/mobile/src/visualizations/chart/__stories__/ChartAccessibility.stories.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import type { View } from 'react-native'; import { assets } from '@coinbase/cds-common/internal/data/assets'; import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; @@ -503,7 +503,13 @@ const AssetPriceWithDottedArea = memo(function AssetPriceWithDottedArea() { ); const BTCTab: TabComponent = memo( - forwardRef(({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + ({ + ref, + label, + ...props + }: SegmentedTabProps & { + ref?: React.Ref; + }) => { const { activeTab } = useTabsContext(); const isActive = activeTab?.id === props.id; return ( @@ -517,7 +523,7 @@ const AssetPriceWithDottedArea = memo(function AssetPriceWithDottedArea() { {...props} /> ); - }), + }, ); const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( ) => { + ({ + ref, + label, + ...props + }: SegmentedTabProps & { + ref?: React.Ref; + }) => { const { activeTab } = useTabsContext(); const isActive = activeTab?.id === props.id; const theme = useTheme(); @@ -208,44 +214,51 @@ const BTCTab: TabComponent = memo( ); return ; - }), + }, ); const BTCLiveLabel = memo( - forwardRef( - ({ label = 'LIVE', font = 'label1', hideDot, style, ...props }, ref) => { - const theme = useTheme(); - - const dotStyle = useMemo( - () => ({ - width: theme.space[1], - height: theme.space[1], - borderRadius: 1000, - marginRight: theme.space[0.75], - backgroundColor: btcColor, - }), - [theme.space], - ); + ({ + ref, + label = 'LIVE', + font = 'label1', + hideDot, + style, + ...props + }: LiveTabLabelProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); - return ( - - {!hideDot && } - - {label} - - - ); - }, - ), + const dotStyle = useMemo( + () => ({ + width: theme.space[1], + height: theme.space[1], + borderRadius: 1000, + marginRight: theme.space[0.75], + backgroundColor: btcColor, + }), + [theme.space], + ); + + return ( + + {!hideDot && } + + {label} + + + ); + }, ); const ColoredPeriodSelectorExample = () => { diff --git a/packages/mobile/src/visualizations/chart/area/AreaChart.tsx b/packages/mobile/src/visualizations/chart/area/AreaChart.tsx index 5318fea569..e47729518c 100644 --- a/packages/mobile/src/visualizations/chart/area/AreaChart.tsx +++ b/packages/mobile/src/visualizations/chart/area/AreaChart.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { View } from 'react-native'; import { XAxis, type XAxisProps, YAxis, type YAxisProps } from '../axis'; @@ -107,114 +107,141 @@ export type AreaChartProps = AreaChartBaseProps & Omit; export const AreaChart = memo( - forwardRef( - ( - { - series, - stacked, - AreaComponent, - curve, - fillOpacity, - type, - connectNulls, - transition, - transitions, - LineComponent, - strokeWidth, - showXAxis, - showYAxis, - showLines, - lineType = 'solid', - xAxis, - yAxis, - inset, - children, - ...chartProps - }, - ref, - ) => { - // Convert AreaSeries to Series for Chart context - const chartSeries = useMemo(() => { - return series?.map( - (s): Series => ({ - id: s.id, - data: s.data, - label: s.label, - color: s.color, - gradient: s.gradient, - xAxisId: s.xAxisId, - yAxisId: s.yAxisId, - stackId: s.stackId, - legendShape: s.legendShape, - }), - ); - }, [series]); + ({ + ref, + series, + stacked, + AreaComponent, + curve, + fillOpacity, + type, + connectNulls, + transition, + transitions, + LineComponent, + strokeWidth, + showXAxis, + showYAxis, + showLines, + lineType = 'solid', + xAxis, + yAxis, + inset, + children, + ...chartProps + }: AreaChartProps & { + ref?: React.Ref; + }) => { + // Convert AreaSeries to Series for Chart context + const chartSeries = useMemo(() => { + return series?.map( + (s): Series => ({ + id: s.id, + data: s.data, + label: s.label, + color: s.color, + gradient: s.gradient, + xAxisId: s.xAxisId, + yAxisId: s.yAxisId, + stackId: s.stackId, + legendShape: s.legendShape, + }), + ); + }, [series]); - const transformedSeries = useMemo(() => { - if (!stacked || !chartSeries) return chartSeries; - return chartSeries.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); - }, [chartSeries, stacked]); + const transformedSeries = useMemo(() => { + if (!stacked || !chartSeries) return chartSeries; + return chartSeries.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); + }, [chartSeries, stacked]); - const seriesToRender = transformedSeries ?? chartSeries; + const seriesToRender = transformedSeries ?? chartSeries; - // Split axis props into config props for Chart and visual props for axis components - const { - scaleType: xScaleType, - data: xData, - categoryPadding: xCategoryPadding, - domain: xDomain, - domainLimit: xDomainLimit, - range: xRange, - baseline: xBaseline, - id: xAxisId, - ...xAxisVisualProps - } = xAxis || {}; - const { - scaleType: yScaleType, - data: yData, - categoryPadding: yCategoryPadding, - domain: yDomain, - domainLimit: yDomainLimit, - range: yRange, - baseline: yBaseline, - id: yAxisId, - ...yAxisVisualProps - } = yAxis || {}; - const isHorizontalLayout = chartProps.layout === 'horizontal'; - const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; + // Split axis props into config props for Chart and visual props for axis components + const { + scaleType: xScaleType, + data: xData, + categoryPadding: xCategoryPadding, + domain: xDomain, + domainLimit: xDomainLimit, + range: xRange, + baseline: xBaseline, + id: xAxisId, + ...xAxisVisualProps + } = xAxis || {}; + const { + scaleType: yScaleType, + data: yData, + categoryPadding: yCategoryPadding, + domain: yDomain, + domainLimit: yDomainLimit, + range: yRange, + baseline: yBaseline, + id: yAxisId, + ...yAxisVisualProps + } = yAxis || {}; + const isHorizontalLayout = chartProps.layout === 'horizontal'; + const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; - const xAxisConfig: Partial = { - scaleType: xScaleType, - data: xData, - categoryPadding: xCategoryPadding, - domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, - domainLimit: xDomainLimit, - range: xRange, - baseline: xBaseline, - }; + const xAxisConfig: Partial = { + scaleType: xScaleType, + data: xData, + categoryPadding: xCategoryPadding, + domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, + domainLimit: xDomainLimit, + range: xRange, + baseline: xBaseline, + }; - const yAxisConfig: Partial = { - scaleType: yScaleType, - data: yData, - categoryPadding: yCategoryPadding, - domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, - domainLimit: yDomainLimit, - range: yRange, - baseline: yBaseline, - }; + const yAxisConfig: Partial = { + scaleType: yScaleType, + data: yData, + categoryPadding: yCategoryPadding, + domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, + domainLimit: yDomainLimit, + range: yRange, + baseline: yBaseline, + }; - return ( - - {showXAxis && } - {showYAxis && } - {series?.map( + return ( + + {showXAxis && } + {showYAxis && } + {series?.map( + ({ + id, + data, + label, + color, + xAxisId, + yAxisId, + opacity, + LineComponent, + stackId, + ...areaPropsFromSeries + }) => ( + + ), + )} + {showLines && + series?.map( ({ id, data, @@ -222,59 +249,30 @@ export const AreaChart = memo( color, xAxisId, yAxisId, - opacity, - LineComponent, + fill, + fillOpacity, stackId, - ...areaPropsFromSeries - }) => ( - - ), + type, // Area type (don't pass to Line) + ...otherPropsFromSeries + }) => { + return ( + + ); + }, )} - {showLines && - series?.map( - ({ - id, - data, - label, - color, - xAxisId, - yAxisId, - fill, - fillOpacity, - stackId, - type, // Area type (don't pass to Line) - ...otherPropsFromSeries - }) => { - return ( - - ); - }, - )} - {children} - - ); - }, - ), + {children} + + ); + }, ); diff --git a/packages/mobile/src/visualizations/chart/bar/BarChart.tsx b/packages/mobile/src/visualizations/chart/bar/BarChart.tsx index 2b6df90990..237619c0cc 100644 --- a/packages/mobile/src/visualizations/chart/bar/BarChart.tsx +++ b/packages/mobile/src/visualizations/chart/bar/BarChart.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { View } from 'react-native'; import { XAxis, type XAxisProps, YAxis, type YAxisProps } from '../axis'; @@ -89,147 +89,145 @@ export type BarChartProps = BarChartBaseProps & >; export const BarChart = memo( - forwardRef( - ( - { - series: seriesProp, - stacked, - showXAxis, - showYAxis, - xAxis, - yAxis, - inset, - children, - barPadding, - BarComponent, - fillOpacity, - stroke, - strokeWidth, - borderRadius, - roundBaseline, - BarStackComponent, - stackGap, - barMinSize, - stackMinSize, - transitions, - transition, - ...chartProps - }, - ref, - ) => { - const series: Array | undefined = useMemo(() => { - if (!stacked || !seriesProp) return seriesProp; - return seriesProp.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); - }, [seriesProp, stacked]); + ({ + ref, + series: seriesProp, + stacked, + showXAxis, + showYAxis, + xAxis, + yAxis, + inset, + children, + barPadding, + BarComponent, + fillOpacity, + stroke, + strokeWidth, + borderRadius, + roundBaseline, + BarStackComponent, + stackGap, + barMinSize, + stackMinSize, + transitions, + transition, + ...chartProps + }: BarChartProps & { + ref?: React.Ref; + }) => { + const series: Array | undefined = useMemo(() => { + if (!stacked || !seriesProp) return seriesProp; + return seriesProp.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); + }, [seriesProp, stacked]); - const seriesIds = useMemo(() => series?.map((s) => s.id), [series]); - const isHorizontalLayout = chartProps.layout === 'horizontal'; - const defaultXScaleType = isHorizontalLayout ? 'linear' : 'band'; - const defaultYScaleType = isHorizontalLayout ? 'band' : 'linear'; + const seriesIds = useMemo(() => series?.map((s) => s.id), [series]); + const isHorizontalLayout = chartProps.layout === 'horizontal'; + const defaultXScaleType = isHorizontalLayout ? 'linear' : 'band'; + const defaultYScaleType = isHorizontalLayout ? 'band' : 'linear'; - // Split axis props into config props for Chart and visual props for axis components - const { - scaleType: xScaleType, + // Split axis props into config props for Chart and visual props for axis components + const { + scaleType: xScaleType, + data: xData, + categoryPadding: xCategoryPadding, + domain: xDomain, + domainLimit: xDomainLimit, + range: xRange, + baseline: xBaseline, + id: xAxisId, + ...xAxisVisualProps + } = xAxis || {}; + const { + scaleType: yScaleType, + data: yData, + categoryPadding: yCategoryPadding, + domain: yDomain, + domainLimit: yDomainLimit, + range: yRange, + baseline: yBaseline, + id: yAxisId, + ...yAxisVisualProps + } = yAxis || {}; + const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; + + const xAxisConfig = useMemo>( + () => ({ + scaleType: xScaleType ?? defaultXScaleType, data: xData, categoryPadding: xCategoryPadding, - domain: xDomain, + domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, domainLimit: xDomainLimit, range: xRange, baseline: xBaseline, - id: xAxisId, - ...xAxisVisualProps - } = xAxis || {}; - const { - scaleType: yScaleType, + }), + [ + xScaleType, + defaultXScaleType, + xData, + xCategoryPadding, + isHorizontalLayout, + xDomain, + xDomainLimit, + xRange, + xBaseline, + valueAxisBaseline, + ], + ); + + const yAxisConfig = useMemo>( + () => ({ + scaleType: yScaleType ?? defaultYScaleType, data: yData, categoryPadding: yCategoryPadding, - domain: yDomain, + domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, domainLimit: yDomainLimit, range: yRange, baseline: yBaseline, - id: yAxisId, - ...yAxisVisualProps - } = yAxis || {}; - const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; - - const xAxisConfig = useMemo>( - () => ({ - scaleType: xScaleType ?? defaultXScaleType, - data: xData, - categoryPadding: xCategoryPadding, - domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, - domainLimit: xDomainLimit, - range: xRange, - baseline: xBaseline, - }), - [ - xScaleType, - defaultXScaleType, - xData, - xCategoryPadding, - isHorizontalLayout, - xDomain, - xDomainLimit, - xRange, - xBaseline, - valueAxisBaseline, - ], - ); - - const yAxisConfig = useMemo>( - () => ({ - scaleType: yScaleType ?? defaultYScaleType, - data: yData, - categoryPadding: yCategoryPadding, - domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, - domainLimit: yDomainLimit, - range: yRange, - baseline: yBaseline, - }), - [ - yScaleType, - defaultYScaleType, - yData, - yCategoryPadding, - isHorizontalLayout, - yDomain, - yDomainLimit, - yRange, - yBaseline, - valueAxisBaseline, - ], - ); + }), + [ + yScaleType, + defaultYScaleType, + yData, + yCategoryPadding, + isHorizontalLayout, + yDomain, + yDomainLimit, + yRange, + yBaseline, + valueAxisBaseline, + ], + ); - return ( - - {showXAxis && } - {showYAxis && } - - {children} - - ); - }, - ), + return ( + + {showXAxis && } + {showYAxis && } + + {children} + + ); + }, ); diff --git a/packages/mobile/src/visualizations/chart/bar/PercentageBarChart.tsx b/packages/mobile/src/visualizations/chart/bar/PercentageBarChart.tsx index 3b1b8a8a0c..eb4e7ffa2c 100644 --- a/packages/mobile/src/visualizations/chart/bar/PercentageBarChart.tsx +++ b/packages/mobile/src/visualizations/chart/bar/PercentageBarChart.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { View } from 'react-native'; import type { BarChartBaseProps, BarChartProps } from './BarChart'; @@ -78,76 +78,74 @@ export type PercentageBarChartProps = PercentageBarChartBaseProps & >; export const PercentageBarChart = memo( - forwardRef( - ( - { - series, - layout = 'horizontal', - roundBaseline = true, - inset = 0, - transitions, - xAxis, - yAxis, - testID, - children, - ...props - }, - ref, - ) => { - const barSeries = useMemo(() => { - const groupCount = Math.max( - 0, - ...(series?.map(({ data }) => (typeof data === 'number' ? 1 : data.length)) ?? []), - ); + ({ + ref, + series, + layout = 'horizontal', + roundBaseline = true, + inset = 0, + transitions, + xAxis, + yAxis, + testID, + children, + ...props + }: PercentageBarChartProps & { + ref?: React.Ref; + }) => { + const barSeries = useMemo(() => { + const groupCount = Math.max( + 0, + ...(series?.map(({ data }) => (typeof data === 'number' ? 1 : data.length)) ?? []), + ); - const totals = Array.from( - { length: groupCount }, - (_, i) => - series?.reduce((sum, { data }) => sum + (unwrapSeriesDataValue(data, i) ?? 0), 0) ?? 0, - ); + const totals = Array.from( + { length: groupCount }, + (_, i) => + series?.reduce((sum, { data }) => sum + (unwrapSeriesDataValue(data, i) ?? 0), 0) ?? 0, + ); - return series?.map((s) => ({ - ...s, - data: Array.from({ length: groupCount }, (_, i) => { - const val = unwrapSeriesDataValue(s.data, i); - return val != null && totals[i] > 0 ? (val / totals[i]) * 100 : null; - }), - })); - }, [series]); + return series?.map((s) => ({ + ...s, + data: Array.from({ length: groupCount }, (_, i) => { + const val = unwrapSeriesDataValue(s.data, i); + return val != null && totals[i] > 0 ? (val / totals[i]) * 100 : null; + }), + })); + }, [series]); - const isHorizontalLayout = layout === 'horizontal'; + const isHorizontalLayout = layout === 'horizontal'; - const xAxisConfig: BarChartProps['xAxis'] = useMemo(() => { - return isHorizontalLayout - ? { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...xAxis } - : { categoryPadding: 0, ...xAxis }; - }, [isHorizontalLayout, xAxis]); + const xAxisConfig: BarChartProps['xAxis'] = useMemo(() => { + return isHorizontalLayout + ? { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...xAxis } + : { categoryPadding: 0, ...xAxis }; + }, [isHorizontalLayout, xAxis]); - const yAxisConfig: BarChartProps['yAxis'] = useMemo(() => { - return isHorizontalLayout - ? { categoryPadding: 0, ...yAxis } - : { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...yAxis }; - }, [isHorizontalLayout, yAxis]); + const yAxisConfig: BarChartProps['yAxis'] = useMemo(() => { + return isHorizontalLayout + ? { categoryPadding: 0, ...yAxis } + : { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...yAxis }; + }, [isHorizontalLayout, yAxis]); - return ( - - {children} - - ); - }, - ), + return ( + + {children} + + ); + }, ); PercentageBarChart.displayName = 'PercentageBarChart'; diff --git a/packages/mobile/src/visualizations/chart/legend/Legend.tsx b/packages/mobile/src/visualizations/chart/legend/Legend.tsx index c1e50d945d..a5286ceba1 100644 --- a/packages/mobile/src/visualizations/chart/legend/Legend.tsx +++ b/packages/mobile/src/visualizations/chart/legend/Legend.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import { Box, type BoxBaseProps, type BoxProps } from '../../../layout'; @@ -128,65 +128,63 @@ export type LegendProps = Omit & }; export const Legend = memo( - forwardRef( - ( - { - flexDirection = 'row', - justifyContent = 'center', - alignItems = flexDirection === 'row' ? 'center' : 'flex-start', - flexWrap = 'wrap', - rowGap = 0.75, - columnGap = 2, - seriesIds, - EntryComponent = DefaultLegendEntry, - ShapeComponent = DefaultLegendShape, - accessibilityLabel = 'Legend', - style, - styles, - ...props - }, - ref, - ) => { - const { series } = useCartesianChartContext(); + ({ + ref, + flexDirection = 'row', + justifyContent = 'center', + alignItems = flexDirection === 'row' ? 'center' : 'flex-start', + flexWrap = 'wrap', + rowGap = 0.75, + columnGap = 2, + seriesIds, + EntryComponent = DefaultLegendEntry, + ShapeComponent = DefaultLegendShape, + accessibilityLabel = 'Legend', + style, + styles, + ...props + }: LegendProps & { + ref?: React.Ref; + }) => { + const { series } = useCartesianChartContext(); - const filteredSeries = useMemo(() => { - if (seriesIds === undefined) return series.filter((s) => s.label !== undefined); - return series.filter((s) => seriesIds.includes(s.id) && s.label !== undefined); - }, [series, seriesIds]); + const filteredSeries = useMemo(() => { + if (seriesIds === undefined) return series.filter((s) => s.label !== undefined); + return series.filter((s) => seriesIds.includes(s.id) && s.label !== undefined); + }, [series, seriesIds]); - if (filteredSeries.length === 0) return; + if (filteredSeries.length === 0) return; - return ( - - {filteredSeries.map((s) => ( - - ))} - - ); - }, - ), + return ( + + {filteredSeries.map((s) => ( + + ))} + + ); + }, ); diff --git a/packages/mobile/src/visualizations/chart/line/LineChart.tsx b/packages/mobile/src/visualizations/chart/line/LineChart.tsx index 527104c430..e39e17276c 100644 --- a/packages/mobile/src/visualizations/chart/line/LineChart.tsx +++ b/packages/mobile/src/visualizations/chart/line/LineChart.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useMemo } from 'react'; +import { memo, useMemo } from 'react'; import type { View } from 'react-native'; import { XAxis, type XAxisProps } from '../axis/XAxis'; @@ -96,147 +96,144 @@ export type LineChartProps = LineChartBaseProps & }; export const LineChart = memo( - forwardRef( - ( - { - series, - showArea, - areaType, - type, - LineComponent, - AreaComponent, - curve, - points, - strokeWidth, - strokeOpacity, - connectNulls, - transition, - transitions, - opacity, - showXAxis, - showYAxis, - xAxis, - yAxis, - inset, - scrubberAccessibilityLabelStep, - layout = 'vertical', - children, - ...chartProps - }, - ref, - ) => { - // Convert LineSeries to Series for Chart context - const chartSeries = useMemo(() => { - return series?.map( - (s): Series => ({ - id: s.id, - data: s.data, - label: s.label, - color: s.color, - xAxisId: s.xAxisId, - yAxisId: s.yAxisId, - stackId: s.stackId, - gradient: s.gradient, - legendShape: s.legendShape, - }), - ); - }, [series]); + ({ + ref, + series, + showArea, + areaType, + type, + LineComponent, + AreaComponent, + curve, + points, + strokeWidth, + strokeOpacity, + connectNulls, + transition, + transitions, + opacity, + showXAxis, + showYAxis, + xAxis, + yAxis, + inset, + scrubberAccessibilityLabelStep, + layout = 'vertical', + children, + ...chartProps + }: LineChartProps & { + ref?: React.Ref; + }) => { + // Convert LineSeries to Series for Chart context + const chartSeries = useMemo(() => { + return series?.map( + (s): Series => ({ + id: s.id, + data: s.data, + label: s.label, + color: s.color, + xAxisId: s.xAxisId, + yAxisId: s.yAxisId, + stackId: s.stackId, + gradient: s.gradient, + legendShape: s.legendShape, + }), + ); + }, [series]); - // Split axis props into config props for Chart and visual props for axis components - const { - scaleType: xScaleType, - data: xData, - categoryPadding: xCategoryPadding, - domain: xDomain, - domainLimit: xDomainLimit, - range: xRange, - baseline: xBaseline, - id: xAxisId, - ...xAxisVisualProps - } = xAxis || {}; - const { - scaleType: yScaleType, - data: yData, - categoryPadding: yCategoryPadding, - domain: yDomain, - domainLimit: yDomainLimit, - range: yRange, - baseline: yBaseline, - id: yAxisId, - ...yAxisVisualProps - } = yAxis || {}; + // Split axis props into config props for Chart and visual props for axis components + const { + scaleType: xScaleType, + data: xData, + categoryPadding: xCategoryPadding, + domain: xDomain, + domainLimit: xDomainLimit, + range: xRange, + baseline: xBaseline, + id: xAxisId, + ...xAxisVisualProps + } = xAxis || {}; + const { + scaleType: yScaleType, + data: yData, + categoryPadding: yCategoryPadding, + domain: yDomain, + domainLimit: yDomainLimit, + range: yRange, + baseline: yBaseline, + id: yAxisId, + ...yAxisVisualProps + } = yAxis || {}; - const xAxisConfig: Partial = { - scaleType: xScaleType, - data: xData, - categoryPadding: xCategoryPadding, - domain: xDomain, - domainLimit: xDomainLimit, - range: xRange, - baseline: xBaseline, - }; + const xAxisConfig: Partial = { + scaleType: xScaleType, + data: xData, + categoryPadding: xCategoryPadding, + domain: xDomain, + domainLimit: xDomainLimit, + range: xRange, + baseline: xBaseline, + }; - const yAxisConfig: Partial = { - scaleType: yScaleType, - data: yData, - categoryPadding: yCategoryPadding, - domain: yDomain, - domainLimit: yDomainLimit, - range: yRange, - baseline: yBaseline, - }; + const yAxisConfig: Partial = { + scaleType: yScaleType, + data: yData, + categoryPadding: yCategoryPadding, + domain: yDomain, + domainLimit: yDomainLimit, + range: yRange, + baseline: yBaseline, + }; - const categoryAxisData = layout === 'horizontal' ? yData : xData; - const lineChartDataLength = useMemo(() => { - if (categoryAxisData && categoryAxisData.length > 0) return categoryAxisData.length; - if (!series || series.length === 0) return 0; - return series.reduce((max, s) => Math.max(max, s.data?.length ?? 0), 0); - }, [categoryAxisData, series]); + const categoryAxisData = layout === 'horizontal' ? yData : xData; + const lineChartDataLength = useMemo(() => { + if (categoryAxisData && categoryAxisData.length > 0) return categoryAxisData.length; + if (!series || series.length === 0) return 0; + return series.reduce((max, s) => Math.max(max, s.data?.length ?? 0), 0); + }, [categoryAxisData, series]); - const resolvedScrubberAccessibilityLabelStep = useMemo( - () => - scrubberAccessibilityLabelStep ?? - getDefaultScrubberAccessibilityStep(lineChartDataLength), - [scrubberAccessibilityLabelStep, lineChartDataLength], - ); + const resolvedScrubberAccessibilityLabelStep = useMemo( + () => + scrubberAccessibilityLabelStep ?? getDefaultScrubberAccessibilityStep(lineChartDataLength), + [scrubberAccessibilityLabelStep, lineChartDataLength], + ); - return ( - - {/* Render axes first for grid lines to appear behind everything else */} - {showXAxis && } - {showYAxis && } - {series?.map(({ id, data, label, color, xAxisId, yAxisId, ...linePropsFromSeries }) => ( - - ))} - {children} - - ); - }, - ), + return ( + + {/* Render axes first for grid lines to appear behind everything else */} + {showXAxis && } + {showYAxis && } + {series?.map(({ id, data, label, color, xAxisId, yAxisId, ...linePropsFromSeries }) => ( + + ))} + {children} + + ); + }, ); diff --git a/packages/mobile/src/visualizations/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile/src/visualizations/chart/line/__stories__/LineChart.stories.tsx index 91e1022ea3..55387c4b23 100644 --- a/packages/mobile/src/visualizations/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/mobile/src/visualizations/chart/line/__stories__/LineChart.stories.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { View } from 'react-native'; import { useAnimatedReaction, @@ -967,7 +967,13 @@ function AssetPriceWithDottedArea() { }, []); const BTCTab: TabComponent = memo( - forwardRef(({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + ({ + ref, + label, + ...props + }: SegmentedTabProps & { + ref?: React.Ref; + }) => { const { activeTab } = useTabsContext(); const isActive = activeTab?.id === props.id; @@ -987,7 +993,7 @@ function AssetPriceWithDottedArea() { {...props} /> ); - }), + }, ); const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( ( - ( - { - seriesId, - color: colorProp, - dataX, - dataY, - isIdle, - idlePulse, - animate = true, - transitions, - opacity: opacityProp = 1, - radius = defaultRadius, - stroke, - strokeWidth = defaultStrokeWidth, + ({ + ref, + seriesId, + color: colorProp, + dataX, + dataY, + isIdle, + idlePulse, + animate = true, + transitions, + opacity: opacityProp = 1, + radius = defaultRadius, + stroke, + strokeWidth = defaultStrokeWidth, + }: DefaultScrubberBeaconProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + const { getSeries, getXSerializableScale, getYSerializableScale, drawingArea } = + useCartesianChartContext(); + + const targetSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); + const xScale = useMemo( + () => getXSerializableScale(targetSeries?.xAxisId), + [getXSerializableScale, targetSeries?.xAxisId], + ); + const yScale = useMemo( + () => getYSerializableScale(targetSeries?.yAxisId), + [getYSerializableScale, targetSeries?.yAxisId], + ); + + const color = useMemo( + () => colorProp ?? targetSeries?.color ?? theme.color.fgPrimary, + [colorProp, targetSeries?.color, theme.color.fgPrimary], + ); + + const updateTransition = useMemo( + () => getTransition(transitions?.update, animate, defaultTransition), + [transitions?.update, animate], + ); + const pulseTransition = useMemo( + () => transitions?.pulse ?? defaultPulseTransition, + [transitions?.pulse], + ); + const pulseRepeatDelay = useMemo( + () => transitions?.pulseRepeatDelay ?? defaultPulseRepeatDelay, + [transitions?.pulseRepeatDelay], + ); + + const pulseRadiusStart = radius * pulseRadiusStartMultiplier; + const pulseRadiusEnd = radius * pulseRadiusEndMultiplier; + + const pulseOpacity = useSharedValue(0); + const pulseRadius = useSharedValue(pulseRadiusStart); + + // Convert idlePulse prop to SharedValue so useAnimatedReaction can detect changes. + // In the new React Native architecture, regular JS props are captured by value in worklets + // and won't update when the prop changes. + const idlePulseShared = useSharedValue(idlePulse ?? false); + useEffect(() => { + idlePulseShared.value = idlePulse ?? false; + }, [idlePulse, idlePulseShared]); + + const animatedX = useSharedValue(null); + const animatedY = useSharedValue(null); + + // Calculate the target point position - project data to pixels + const targetPoint = useDerivedValue(() => { + if (!xScale || !yScale) return { x: 0, y: 0 }; + return projectPointWithSerializableScale({ + x: unwrapAnimatedValue(dataX), + y: unwrapAnimatedValue(dataY), + xScale, + yScale, + }); + }, [dataX, dataY, xScale, yScale]); + + useAnimatedReaction( + () => { + return { point: targetPoint.value, isIdle: unwrapAnimatedValue(isIdle) }; }, - ref, - ) => { - const theme = useTheme(); - const { getSeries, getXSerializableScale, getYSerializableScale, drawingArea } = - useCartesianChartContext(); - - const targetSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); - const xScale = useMemo( - () => getXSerializableScale(targetSeries?.xAxisId), - [getXSerializableScale, targetSeries?.xAxisId], - ); - const yScale = useMemo( - () => getYSerializableScale(targetSeries?.yAxisId), - [getYSerializableScale, targetSeries?.yAxisId], - ); - - const color = useMemo( - () => colorProp ?? targetSeries?.color ?? theme.color.fgPrimary, - [colorProp, targetSeries?.color, theme.color.fgPrimary], - ); - - const updateTransition = useMemo( - () => getTransition(transitions?.update, animate, defaultTransition), - [transitions?.update, animate], - ); - const pulseTransition = useMemo( - () => transitions?.pulse ?? defaultPulseTransition, - [transitions?.pulse], - ); - const pulseRepeatDelay = useMemo( - () => transitions?.pulseRepeatDelay ?? defaultPulseRepeatDelay, - [transitions?.pulseRepeatDelay], - ); - - const pulseRadiusStart = radius * pulseRadiusStartMultiplier; - const pulseRadiusEnd = radius * pulseRadiusEndMultiplier; - - const pulseOpacity = useSharedValue(0); - const pulseRadius = useSharedValue(pulseRadiusStart); - - // Convert idlePulse prop to SharedValue so useAnimatedReaction can detect changes. - // In the new React Native architecture, regular JS props are captured by value in worklets - // and won't update when the prop changes. - const idlePulseShared = useSharedValue(idlePulse ?? false); - useEffect(() => { - idlePulseShared.value = idlePulse ?? false; - }, [idlePulse, idlePulseShared]); - - const animatedX = useSharedValue(null); - const animatedY = useSharedValue(null); - - // Calculate the target point position - project data to pixels - const targetPoint = useDerivedValue(() => { - if (!xScale || !yScale) return { x: 0, y: 0 }; - return projectPointWithSerializableScale({ - x: unwrapAnimatedValue(dataX), - y: unwrapAnimatedValue(dataY), - xScale, - yScale, - }); - }, [dataX, dataY, xScale, yScale]); - - useAnimatedReaction( - () => { - return { point: targetPoint.value, isIdle: unwrapAnimatedValue(isIdle) }; - }, - (current, previous) => { - // When animation is disabled, on initial render, or when we are starting, - // continuing, or finishing scrubbing we should immediately transition - if (!animate || previous === null || !previous.isIdle || !current.isIdle) { - animatedX.value = current.point.x; - animatedY.value = current.point.y; - return; - } + (current, previous) => { + // When animation is disabled, on initial render, or when we are starting, + // continuing, or finishing scrubbing we should immediately transition + if (!animate || previous === null || !previous.isIdle || !current.isIdle) { + animatedX.value = current.point.x; + animatedY.value = current.point.y; + return; + } + + animatedX.value = buildTransition(current.point.x, updateTransition); + animatedY.value = buildTransition(current.point.y, updateTransition); + }, + [animate, updateTransition], + ); - animatedX.value = buildTransition(current.point.x, updateTransition); - animatedY.value = buildTransition(current.point.y, updateTransition); - }, - [animate, updateTransition], - ); - - // Create animated point using the animated values - const animatedPoint = useDerivedValue(() => { - // If the animated values have not been set yet, return the target point - if (animatedX.value === null || animatedY.value === null) return targetPoint.value; - return { x: animatedX.value, y: animatedY.value }; - }, [targetPoint, animatedX, animatedY]); - - useImperativeHandle( - ref, - () => ({ - pulse: () => { - // Only trigger manual pulse when idlePulse is not enabled - if (!idlePulseShared.value) { - cancelAnimation(pulseOpacity); - cancelAnimation(pulseRadius); - - // Manual pulse without delay - pulseOpacity.value = pulseOpacityStart; - pulseRadius.value = pulseRadiusStart; - pulseOpacity.value = buildTransition(pulseOpacityEnd, pulseTransition); - pulseRadius.value = buildTransition(pulseRadiusEnd, pulseTransition); - } - }, - }), - [ - idlePulseShared, - pulseOpacity, - pulseRadius, - pulseTransition, - pulseRadiusStart, - pulseRadiusEnd, - ], - ); - - // Watch idlePulse changes and control continuous pulse - useAnimatedReaction( - () => idlePulseShared.value, - (current) => { - if (current) { - // Start continuous pulse when idlePulse is enabled - pulseOpacity.value = pulseOpacityStart; - pulseRadius.value = pulseRadiusStart; + // Create animated point using the animated values + const animatedPoint = useDerivedValue(() => { + // If the animated values have not been set yet, return the target point + if (animatedX.value === null || animatedY.value === null) return targetPoint.value; + return { x: animatedX.value, y: animatedY.value }; + }, [targetPoint, animatedX, animatedY]); - pulseOpacity.value = withRepeat( - withSequence( - buildTransition(pulseOpacityEnd, pulseTransition), - withDelay(pulseRepeatDelay, withTiming(pulseOpacityStart, { duration: 0 })), - ), - -1, // infinite loop - false, - ); - - pulseRadius.value = withRepeat( - withSequence( - buildTransition(pulseRadiusEnd, pulseTransition), - withDelay(pulseRepeatDelay, withTiming(pulseRadiusStart, { duration: 0 })), - ), - -1, // infinite loop - false, - ); - } else { - // Stop pulse when idlePulse is disabled + useImperativeHandle( + ref, + () => ({ + pulse: () => { + // Only trigger manual pulse when idlePulse is not enabled + if (!idlePulseShared.value) { cancelAnimation(pulseOpacity); cancelAnimation(pulseRadius); - pulseOpacity.value = pulseOpacityEnd; + + // Manual pulse without delay + pulseOpacity.value = pulseOpacityStart; pulseRadius.value = pulseRadiusStart; + pulseOpacity.value = buildTransition(pulseOpacityEnd, pulseTransition); + pulseRadius.value = buildTransition(pulseRadiusEnd, pulseTransition); } }, - [pulseTransition, pulseRepeatDelay, pulseRadiusStart, pulseRadiusEnd], - ); - - const pulseVisibility = useDerivedValue(() => { - // Never pulse when scrubbing - if (!unwrapAnimatedValue(isIdle)) return 0; - return pulseOpacity.value; - }, [isIdle, pulseOpacity]); - - const beaconOpacity = useDerivedValue(() => { - const point = targetPoint.value; - const isWithinDrawingArea = - point.x >= drawingArea.x && - point.x <= drawingArea.x + drawingArea.width && - point.y >= drawingArea.y && - point.y <= drawingArea.y + drawingArea.height; - const userOpacity = unwrapAnimatedValue(opacityProp); - return isWithinDrawingArea ? userOpacity : 0; - }, [targetPoint, drawingArea, opacityProp]); - - return ( - - - - - - ); - }, - ), + }), + [ + idlePulseShared, + pulseOpacity, + pulseRadius, + pulseTransition, + pulseRadiusStart, + pulseRadiusEnd, + ], + ); + + // Watch idlePulse changes and control continuous pulse + useAnimatedReaction( + () => idlePulseShared.value, + (current) => { + if (current) { + // Start continuous pulse when idlePulse is enabled + pulseOpacity.value = pulseOpacityStart; + pulseRadius.value = pulseRadiusStart; + + pulseOpacity.value = withRepeat( + withSequence( + buildTransition(pulseOpacityEnd, pulseTransition), + withDelay(pulseRepeatDelay, withTiming(pulseOpacityStart, { duration: 0 })), + ), + -1, // infinite loop + false, + ); + + pulseRadius.value = withRepeat( + withSequence( + buildTransition(pulseRadiusEnd, pulseTransition), + withDelay(pulseRepeatDelay, withTiming(pulseRadiusStart, { duration: 0 })), + ), + -1, // infinite loop + false, + ); + } else { + // Stop pulse when idlePulse is disabled + cancelAnimation(pulseOpacity); + cancelAnimation(pulseRadius); + pulseOpacity.value = pulseOpacityEnd; + pulseRadius.value = pulseRadiusStart; + } + }, + [pulseTransition, pulseRepeatDelay, pulseRadiusStart, pulseRadiusEnd], + ); + + const pulseVisibility = useDerivedValue(() => { + // Never pulse when scrubbing + if (!unwrapAnimatedValue(isIdle)) return 0; + return pulseOpacity.value; + }, [isIdle, pulseOpacity]); + + const beaconOpacity = useDerivedValue(() => { + const point = targetPoint.value; + const isWithinDrawingArea = + point.x >= drawingArea.x && + point.x <= drawingArea.x + drawingArea.width && + point.y >= drawingArea.y && + point.y <= drawingArea.y + drawingArea.height; + const userOpacity = unwrapAnimatedValue(opacityProp); + return isWithinDrawingArea ? userOpacity : 0; + }, [targetPoint, drawingArea, opacityProp]); + + return ( + + + + + + ); + }, ); diff --git a/packages/mobile/src/visualizations/chart/scrubber/Scrubber.tsx b/packages/mobile/src/visualizations/chart/scrubber/Scrubber.tsx index 7535cafdf1..078abe5e7b 100644 --- a/packages/mobile/src/visualizations/chart/scrubber/Scrubber.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/Scrubber.tsx @@ -1,11 +1,4 @@ -import React, { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useMemo, -} from 'react'; +import React, { memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'; import { runOnJS, useAnimatedReaction, @@ -257,234 +250,232 @@ export type ScrubberRef = ScrubberBeaconGroupRef; * Unified component that manages all scrubber elements (beacons, line, labels). */ export const Scrubber = memo( - forwardRef( - ( - { - seriesIds, - hideBeaconLabels, - hideLine, - label, - lineStroke, - BeaconComponent = DefaultScrubberBeacon, - BeaconLabelComponent, - LineComponent, - LabelComponent = DefaultScrubberLabel, - labelElevated, - hideOverlay, - overlayOffset = 2, - beaconLabelMinGap, - beaconLabelHorizontalOffset, - beaconLabelPreferredSide, - labelFont, - labelBoundsInset, - beaconLabelFont, - idlePulse, - beaconTransitions, - transitions = beaconTransitions, - beaconStroke, + ({ + ref, + seriesIds, + hideBeaconLabels, + hideLine, + label, + lineStroke, + BeaconComponent = DefaultScrubberBeacon, + BeaconLabelComponent, + LineComponent, + LabelComponent = DefaultScrubberLabel, + labelElevated, + hideOverlay, + overlayOffset = 2, + beaconLabelMinGap, + beaconLabelHorizontalOffset, + beaconLabelPreferredSide, + labelFont, + labelBoundsInset, + beaconLabelFont, + idlePulse, + beaconTransitions, + transitions = beaconTransitions, + beaconStroke, + }: ScrubberProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + const beaconGroupRef = React.useRef(null); + + const { scrubberPosition } = useScrubberContext(); + const { + layout, + getXSerializableScale, + getYSerializableScale, + getXAxis, + getYAxis, + series, + drawingArea, + animate, + dataLength, + } = useCartesianChartContext(); + + const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]); + const indexAxis = useMemo( + () => (categoryAxisIsX ? getXAxis() : getYAxis()), + [categoryAxisIsX, getXAxis, getYAxis], + ); + const indexScale = useMemo( + () => (categoryAxisIsX ? getXSerializableScale() : getYSerializableScale()), + [categoryAxisIsX, getXSerializableScale, getYSerializableScale], + ); + + // Animation state for delayed scrubber rendering (matches web timing) + const scrubberOpacity = useSharedValue(animate ? 0 : 1); + + // Expose imperative handle with pulse method + useImperativeHandle(ref, () => ({ + pulse: () => { + beaconGroupRef.current?.pulse(); }, - ref, - ) => { - const theme = useTheme(); - const beaconGroupRef = React.useRef(null); - - const { scrubberPosition } = useScrubberContext(); - const { - layout, - getXSerializableScale, - getYSerializableScale, - getXAxis, - getYAxis, - series, - drawingArea, - animate, - dataLength, - } = useCartesianChartContext(); - - const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]); - const indexAxis = useMemo( - () => (categoryAxisIsX ? getXAxis() : getYAxis()), - [categoryAxisIsX, getXAxis, getYAxis], - ); - const indexScale = useMemo( - () => (categoryAxisIsX ? getXSerializableScale() : getYSerializableScale()), - [categoryAxisIsX, getXSerializableScale, getYSerializableScale], - ); - - // Animation state for delayed scrubber rendering (matches web timing) - const scrubberOpacity = useSharedValue(animate ? 0 : 1); - - // Expose imperative handle with pulse method - useImperativeHandle(ref, () => ({ - pulse: () => { - beaconGroupRef.current?.pulse(); - }, - })); - - const filteredSeriesIds = useMemo(() => { - if (seriesIds === undefined) { - return series?.map((s) => s.id) ?? []; + })); + + const filteredSeriesIds = useMemo(() => { + if (seriesIds === undefined) { + return series?.map((s) => s.id) ?? []; + } + return seriesIds; + }, [series, seriesIds]); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const dataValue = useDerivedValue(() => { + if ( + indexAxis?.data && + Array.isArray(indexAxis.data) && + indexAxis.data[dataIndex.value] !== undefined + ) { + const axisValue = indexAxis.data[dataIndex.value]; + return typeof axisValue === 'string' ? dataIndex.value : axisValue; + } + return dataIndex.value; + }, [indexAxis, dataIndex]); + + const lineOpacity = useDerivedValue(() => { + return scrubberPosition.value !== undefined ? 1 : 0; + }, [scrubberPosition]); + + const overlayOpacity = useDerivedValue(() => { + return scrubberPosition.value !== undefined ? 0.8 : 0; + }, [scrubberPosition]); + + const pixelPosition = useDerivedValue(() => { + if (dataValue.value === undefined || !indexScale) return undefined; + return getPointOnSerializableScale(dataValue.value, indexScale); + }, [dataValue, indexScale]); + + const overlayWidth = useDerivedValue(() => { + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX + ? drawingArea.x + drawingArea.width - pixel + overlayOffset + : drawingArea.width + overlayOffset * 2; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); + + const overlayHeight = useDerivedValue(() => { + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX + ? drawingArea.height + overlayOffset * 2 + : drawingArea.y + drawingArea.height - pixel + overlayOffset; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); + + const overlayX = useDerivedValue(() => { + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX ? pixel : drawingArea.x - overlayOffset; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); + + const overlayY = useDerivedValue(() => { + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX ? drawingArea.y - overlayOffset : pixel; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); + + const resolvedLabelValue = useSharedValue(''); + + const updateResolvedLabel = useCallback( + (index: number) => { + if (!label) { + resolvedLabelValue.value = ''; + return; } - return seriesIds; - }, [series, seriesIds]); - - const dataIndex = useDerivedValue(() => { - return scrubberPosition.value ?? Math.max(0, dataLength - 1); - }, [scrubberPosition, dataLength]); - - const dataValue = useDerivedValue(() => { - if ( - indexAxis?.data && - Array.isArray(indexAxis.data) && - indexAxis.data[dataIndex.value] !== undefined - ) { - const axisValue = indexAxis.data[dataIndex.value]; - return typeof axisValue === 'string' ? dataIndex.value : axisValue; - } - return dataIndex.value; - }, [indexAxis, dataIndex]); - - const lineOpacity = useDerivedValue(() => { - return scrubberPosition.value !== undefined ? 1 : 0; - }, [scrubberPosition]); - - const overlayOpacity = useDerivedValue(() => { - return scrubberPosition.value !== undefined ? 0.8 : 0; - }, [scrubberPosition]); - - const pixelPosition = useDerivedValue(() => { - if (dataValue.value === undefined || !indexScale) return undefined; - return getPointOnSerializableScale(dataValue.value, indexScale); - }, [dataValue, indexScale]); - - const overlayWidth = useDerivedValue(() => { - const pixel = pixelPosition.value ?? 0; - return categoryAxisIsX - ? drawingArea.x + drawingArea.width - pixel + overlayOffset - : drawingArea.width + overlayOffset * 2; - }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); - - const overlayHeight = useDerivedValue(() => { - const pixel = pixelPosition.value ?? 0; - return categoryAxisIsX - ? drawingArea.height + overlayOffset * 2 - : drawingArea.y + drawingArea.height - pixel + overlayOffset; - }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); - - const overlayX = useDerivedValue(() => { - const pixel = pixelPosition.value ?? 0; - return categoryAxisIsX ? pixel : drawingArea.x - overlayOffset; - }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); - - const overlayY = useDerivedValue(() => { - const pixel = pixelPosition.value ?? 0; - return categoryAxisIsX ? drawingArea.y - overlayOffset : pixel; - }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); - - const resolvedLabelValue = useSharedValue(''); - - const updateResolvedLabel = useCallback( - (index: number) => { - if (!label) { - resolvedLabelValue.value = ''; - return; - } - - if (typeof label === 'function') { - const result = label(index); - resolvedLabelValue.value = result ?? ''; - } else if (typeof label === 'string') { - resolvedLabelValue.value = label; - } - }, - [label, resolvedLabelValue], - ); - - // Update resolved label when dataIndex changes - useAnimatedReaction( - () => dataIndex.value, - (currentIndex) => { - 'worklet'; - runOnJS(updateResolvedLabel)(currentIndex); - }, - [updateResolvedLabel], - ); - - const beaconLabels: ScrubberBeaconLabelGroupBaseProps['labels'] = useMemo( - () => - series - ?.filter((s) => filteredSeriesIds.includes(s.id)) - .filter((s) => s.label !== undefined && s.label.length > 0) - .map((s) => ({ - seriesId: s.id, - label: s.label!, - color: s.color, - })) ?? [], - [series, filteredSeriesIds], - ); - - const showBeaconLabels = !hideBeaconLabels && categoryAxisIsX && beaconLabels.length > 0; - const isReady = !!indexScale; - - const groupEnterTransition = useMemo( - () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), - [transitions?.enter, animate], - ); - - useEffect(() => { - if (animate && isReady) { - scrubberOpacity.value = buildTransition(1, groupEnterTransition); + + if (typeof label === 'function') { + const result = label(index); + resolvedLabelValue.value = result ?? ''; + } else if (typeof label === 'string') { + resolvedLabelValue.value = label; } - }, [animate, isReady, scrubberOpacity, groupEnterTransition]); - - if (!isReady) return; - - return ( - }> - {!hideOverlay && ( - - )} - {!hideLine && ( - - )} - dataIndex.value, + (currentIndex) => { + 'worklet'; + runOnJS(updateResolvedLabel)(currentIndex); + }, + [updateResolvedLabel], + ); + + const beaconLabels: ScrubberBeaconLabelGroupBaseProps['labels'] = useMemo( + () => + series + ?.filter((s) => filteredSeriesIds.includes(s.id)) + .filter((s) => s.label !== undefined && s.label.length > 0) + .map((s) => ({ + seriesId: s.id, + label: s.label!, + color: s.color, + })) ?? [], + [series, filteredSeriesIds], + ); + + const showBeaconLabels = !hideBeaconLabels && categoryAxisIsX && beaconLabels.length > 0; + const isReady = !!indexScale; + + const groupEnterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), + [transitions?.enter, animate], + ); + + useEffect(() => { + if (animate && isReady) { + scrubberOpacity.value = buildTransition(1, groupEnterTransition); + } + }, [animate, isReady, scrubberOpacity, groupEnterTransition]); + + if (!isReady) return; + + return ( + }> + {!hideOverlay && ( + + )} + {!hideLine && ( + + )} + + {showBeaconLabels && ( + - {showBeaconLabels && ( - - )} - - ); - }, - ), + )} + + ); + }, ); diff --git a/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconGroup.tsx b/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconGroup.tsx index 781ef90335..6bf1360e26 100644 --- a/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconGroup.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconGroup.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useImperativeHandle, useMemo } from 'react'; +import { memo, useCallback, useImperativeHandle, useMemo } from 'react'; import type { SharedValue } from 'react-native-reanimated'; import { useDerivedValue } from 'react-native-reanimated'; import { useRefMap } from '@coinbase/cds-common/hooks/useRefMap'; @@ -169,82 +169,85 @@ export type ScrubberBeaconGroupProps = ScrubberBeaconGroupBaseProps & { }; export const ScrubberBeaconGroup = memo( - forwardRef( - ( - { seriesIds, idlePulse, transitions, BeaconComponent = DefaultScrubberBeacon, stroke }, - ref, - ) => { - const ScrubberBeaconRefs = useRefMap(); - const { scrubberPosition } = useScrubberContext(); - const { layout, getXAxis, getYAxis, series, dataLength, animate } = - useCartesianChartContext(); - - const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]); - const indexAxis = useMemo( - () => (categoryAxisIsX ? getXAxis() : getYAxis()), - [categoryAxisIsX, getXAxis, getYAxis], - ); - - // Expose imperative handle with pulse method - useImperativeHandle(ref, () => ({ - pulse: () => { - Object.values(ScrubberBeaconRefs.refs).forEach((beaconRef) => { - beaconRef?.pulse(); - }); - }, - })); - - const filteredSeries = useMemo(() => { - return series?.filter((s) => seriesIds.includes(s.id)) ?? []; - }, [series, seriesIds]); - - const dataIndex = useDerivedValue(() => { - return scrubberPosition.value ?? Math.max(0, dataLength - 1); - }, [scrubberPosition, dataLength]); - - const dataIndexValue = useDerivedValue(() => { - // Convert index to actual category-axis value if axis has data. - if ( - indexAxis?.data && - Array.isArray(indexAxis.data) && - indexAxis.data[dataIndex.value] !== undefined - ) { - const dataValue = indexAxis.data[dataIndex.value]; - return typeof dataValue === 'string' ? dataIndex.value : dataValue; - } - return dataIndex.value; - }, [indexAxis, dataIndex]); - - const isIdle = useDerivedValue(() => { - return scrubberPosition.value === undefined; - }, [scrubberPosition]); - - const createBeaconRef = useCallback( - (seriesId: string) => { - return (beaconRef: ScrubberBeaconRef | null) => { - if (beaconRef) { - ScrubberBeaconRefs.registerRef(seriesId, beaconRef); - } - }; - }, - [ScrubberBeaconRefs], - ); - - return filteredSeries.map((s) => ( - - )); - }, - ), + ({ + ref, + seriesIds, + idlePulse, + transitions, + BeaconComponent = DefaultScrubberBeacon, + stroke, + }: ScrubberBeaconGroupProps & { + ref?: React.Ref; + }) => { + const ScrubberBeaconRefs = useRefMap(); + const { scrubberPosition } = useScrubberContext(); + const { layout, getXAxis, getYAxis, series, dataLength, animate } = useCartesianChartContext(); + + const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]); + const indexAxis = useMemo( + () => (categoryAxisIsX ? getXAxis() : getYAxis()), + [categoryAxisIsX, getXAxis, getYAxis], + ); + + // Expose imperative handle with pulse method + useImperativeHandle(ref, () => ({ + pulse: () => { + Object.values(ScrubberBeaconRefs.refs).forEach((beaconRef) => { + beaconRef?.pulse(); + }); + }, + })); + + const filteredSeries = useMemo(() => { + return series?.filter((s) => seriesIds.includes(s.id)) ?? []; + }, [series, seriesIds]); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const dataIndexValue = useDerivedValue(() => { + // Convert index to actual category-axis value if axis has data. + if ( + indexAxis?.data && + Array.isArray(indexAxis.data) && + indexAxis.data[dataIndex.value] !== undefined + ) { + const dataValue = indexAxis.data[dataIndex.value]; + return typeof dataValue === 'string' ? dataIndex.value : dataValue; + } + return dataIndex.value; + }, [indexAxis, dataIndex]); + + const isIdle = useDerivedValue(() => { + return scrubberPosition.value === undefined; + }, [scrubberPosition]); + + const createBeaconRef = useCallback( + (seriesId: string) => { + return (beaconRef: ScrubberBeaconRef | null) => { + if (beaconRef) { + ScrubberBeaconRefs.registerRef(seriesId, beaconRef); + } + }; + }, + [ScrubberBeaconRefs], + ); + + return filteredSeries.map((s) => ( + + )); + }, ); diff --git a/packages/mobile/src/visualizations/sparkline/Sparkline.tsx b/packages/mobile/src/visualizations/sparkline/Sparkline.tsx index acf0beb732..9d13b5f2d7 100644 --- a/packages/mobile/src/visualizations/sparkline/Sparkline.tsx +++ b/packages/mobile/src/visualizations/sparkline/Sparkline.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo, useRef } from 'react'; +import React, { memo, useMemo, useRef } from 'react'; import { Defs, G, LinearGradient, Mask, Path, Rect, Stop, Svg } from 'react-native-svg'; import { getAccessibleForegroundGradient } from '@coinbase/cds-common/color/getAccessibleForegroundGradient'; import { borderWidth } from '@coinbase/cds-common/tokens/sparkline'; @@ -54,158 +54,156 @@ export type SparklineProps = SparklineBaseProps; * @deprecationExpectedRemoval v4 */ export const Sparkline = memo( - forwardRef( - ( - { - background, + ({ + ref, + background, + color, + height, + path, + width, + yAxisScalingFactor, + children, + strokeType = 'solid', + fillType = 'dotted', + }: SparklineProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + const patternId = useRef(generateRandomId()); + + const strokeColor = + color !== 'auto' + ? color + : getAccessibleColor({ + background: background ?? theme.color.bg, + foreground: 'auto', + usage: 'graphic', + }); + + const translateProps = getSparklineTransform(width, height, yAxisScalingFactor); + const hasChildren = !!children; + const useModernFill = fillType === 'gradient' || fillType === 'gradientDotted'; + + // Stroke gradient (for strokeType='gradient') + const strokeGradient = useMemo(() => { + if (strokeType !== 'gradient') return null; + + return getAccessibleForegroundGradient({ + background: background ?? theme.color.bg, color, - height, - path, - width, - yAxisScalingFactor, - children, - strokeType = 'solid', - fillType = 'dotted', - }, - ref, - ) => { - const theme = useTheme(); - const patternId = useRef(generateRandomId()); - - const strokeColor = - color !== 'auto' - ? color - : getAccessibleColor({ - background: background ?? theme.color.bg, - foreground: 'auto', - usage: 'graphic', - }); - - const translateProps = getSparklineTransform(width, height, yAxisScalingFactor); - const hasChildren = !!children; - const useModernFill = fillType === 'gradient' || fillType === 'gradientDotted'; - - // Stroke gradient (for strokeType='gradient') - const strokeGradient = useMemo(() => { - if (strokeType !== 'gradient') return null; - - return getAccessibleForegroundGradient({ - background: background ?? theme.color.bg, - color, - colorScheme: theme.activeColorScheme, - usage: 'graphic', - }); - }, [strokeType, background, color, theme]); - - // Calculate gradient coordinates for modern fills - const { gradientY1, gradientY2 } = useMemo(() => { - if (!useModernFill) return { gradientY1: 0, gradientY2: 0 }; - - if (!Number.isFinite(yAxisScalingFactor)) { - return { gradientY1: 2, gradientY2: height - 2 }; - } - - const { yRange } = getSparklineRange({ height, width, yAxisScalingFactor }); - const pathHeight = Math.abs(yRange[0] - yRange[1]); - const yTranslate = height / 2 - pathHeight / 2; - - return { - gradientY1: yRange[1], - gradientY2: height - yTranslate, - }; - }, [useModernFill, height, width, yAxisScalingFactor]); - - const maskGradientId = `${patternId.current}-mask-gradient`; - const maskId = `${patternId.current}-mask`; - - const defs = useMemo(() => { - if (!strokeGradient && !hasChildren) return null; - - return ( - - {strokeGradient && ( - - {strokeGradient.map((item, i) => ( - - ))} - - )} - {hasChildren && fillType === 'dotted' && ( - - )} - {hasChildren && fillType === 'gradient' && ( + colorScheme: theme.activeColorScheme, + usage: 'graphic', + }); + }, [strokeType, background, color, theme]); + + // Calculate gradient coordinates for modern fills + const { gradientY1, gradientY2 } = useMemo(() => { + if (!useModernFill) return { gradientY1: 0, gradientY2: 0 }; + + if (!Number.isFinite(yAxisScalingFactor)) { + return { gradientY1: 2, gradientY2: height - 2 }; + } + + const { yRange } = getSparklineRange({ height, width, yAxisScalingFactor }); + const pathHeight = Math.abs(yRange[0] - yRange[1]); + const yTranslate = height / 2 - pathHeight / 2; + + return { + gradientY1: yRange[1], + gradientY2: height - yTranslate, + }; + }, [useModernFill, height, width, yAxisScalingFactor]); + + const maskGradientId = `${patternId.current}-mask-gradient`; + const maskId = `${patternId.current}-mask`; + + const defs = useMemo(() => { + if (!strokeGradient && !hasChildren) return null; + + return ( + + {strokeGradient && ( + + {strokeGradient.map((item, i) => ( + + ))} + + )} + {hasChildren && fillType === 'dotted' && ( + + )} + {hasChildren && fillType === 'gradient' && ( + + + + + )} + {hasChildren && fillType === 'gradientDotted' && ( + <> + - - + + - )} - {hasChildren && fillType === 'gradientDotted' && ( - <> - - - - - - - - - - )} - - ); - }, [ - strokeGradient, - hasChildren, - fillType, - strokeColor, - gradientY1, - gradientY2, - height, - width, - maskGradientId, - maskId, - ]); - - const stroke = strokeType === 'gradient' ? 'url(#gradient)' : strokeColor; - const shouldPlaceDefsInside = useModernFill; - - return ( - - {!shouldPlaceDefsInside && defs} - - {shouldPlaceDefsInside && defs} - - {generateSparklineAreaWithId( - patternId.current, - children, - fillType === 'gradientDotted' ? maskId : undefined, - )} - - + + + + + )} + ); - }, - ), + }, [ + strokeGradient, + hasChildren, + fillType, + strokeColor, + gradientY1, + gradientY2, + height, + width, + maskGradientId, + maskId, + ]); + + const stroke = strokeType === 'gradient' ? 'url(#gradient)' : strokeColor; + const shouldPlaceDefsInside = useModernFill; + + return ( + + {!shouldPlaceDefsInside && defs} + + {shouldPlaceDefsInside && defs} + + {generateSparklineAreaWithId( + patternId.current, + children, + fillType === 'gradientDotted' ? maskId : undefined, + )} + + + ); + }, ); Sparkline.displayName = 'Sparkline'; diff --git a/packages/mobile/src/visualizations/sparkline/SparklineArea.tsx b/packages/mobile/src/visualizations/sparkline/SparklineArea.tsx index 1a4119e342..23c8b192d9 100644 --- a/packages/mobile/src/visualizations/sparkline/SparklineArea.tsx +++ b/packages/mobile/src/visualizations/sparkline/SparklineArea.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { memo } from 'react'; import { Path } from 'react-native-svg'; export type SparklineAreaBaseProps = { @@ -12,16 +12,21 @@ export type SparklineAreaBaseProps = { * @deprecationExpectedRemoval v4 */ export const SparklineArea = memo( - forwardRef( - ({ area, patternId, maskId }: SparklineAreaBaseProps, ref) => { - return ( - - ); - }, - ), + ({ + ref, + area, + patternId, + maskId, + }: SparklineAreaBaseProps & { + ref?: React.Ref; + }) => { + return ( + + ); + }, ); diff --git a/packages/mobile/src/visualizations/sparkline/SparklineGradient.tsx b/packages/mobile/src/visualizations/sparkline/SparklineGradient.tsx index 6016d2fbd7..ae88dca275 100644 --- a/packages/mobile/src/visualizations/sparkline/SparklineGradient.tsx +++ b/packages/mobile/src/visualizations/sparkline/SparklineGradient.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo, useRef } from 'react'; +import React, { memo, useMemo, useRef } from 'react'; import { Defs, G, LinearGradient, Path, Stop, Svg } from 'react-native-svg'; import { getAccessibleForegroundGradient } from '@coinbase/cds-common/color/getAccessibleForegroundGradient'; import { borderWidth } from '@coinbase/cds-common/tokens/sparkline'; @@ -17,58 +17,67 @@ import { SparklineAreaPattern } from './SparklineAreaPattern'; * @deprecationExpectedRemoval v4 */ export const SparklineGradient = memo( - forwardRef( - ({ background, color, path, height, width, yAxisScalingFactor, children }, ref) => { - const theme = useTheme(); - const patternId = useRef(generateRandomId()); - const translateProps = getSparklineTransform(width, height, yAxisScalingFactor); - const gradient = getAccessibleForegroundGradient({ - background: background ?? theme.color.bg, - color, - colorScheme: theme.activeColorScheme, - usage: 'graphic', - }); - const areaColor = - color !== 'auto' - ? color - : getAccessibleColor({ - background: background ?? theme.color.bg, - foreground: 'auto', - usage: 'graphic', - }); - - const hasChildren = !!children; - const linearGradient = useMemo(() => { - return ( - - - {gradient.map((item, i) => ( - - ))} - - {hasChildren && } - - ); - }, [areaColor, hasChildren, gradient]); + ({ + ref, + background, + color, + path, + height, + width, + yAxisScalingFactor, + children, + }: SparklineBaseProps & { + ref?: React.Ref; + }) => { + const theme = useTheme(); + const patternId = useRef(generateRandomId()); + const translateProps = getSparklineTransform(width, height, yAxisScalingFactor); + const gradient = getAccessibleForegroundGradient({ + background: background ?? theme.color.bg, + color, + colorScheme: theme.activeColorScheme, + usage: 'graphic', + }); + const areaColor = + color !== 'auto' + ? color + : getAccessibleColor({ + background: background ?? theme.color.bg, + foreground: 'auto', + usage: 'graphic', + }); + const hasChildren = !!children; + const linearGradient = useMemo(() => { return ( - - {linearGradient} - - - {generateSparklineAreaWithId(patternId.current, children)} - - + + + {gradient.map((item, i) => ( + + ))} + + {hasChildren && } + ); - }, - ), + }, [areaColor, hasChildren, gradient]); + + return ( + + {linearGradient} + + + {generateSparklineAreaWithId(patternId.current, children)} + + + ); + }, ); SparklineGradient.displayName = 'SparklineGradient'; diff --git a/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx index e5b945fd18..781a4b54c4 100644 --- a/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react'; +import React, { memo, useCallback, useImperativeHandle, useRef } from 'react'; import { TextInput, View } from 'react-native'; import type { FunctionComponent, ReactNode } from 'react'; import { subheadIconSignMap } from '@coinbase/cds-common/tokens/sparkline'; @@ -117,199 +117,207 @@ const Trailing: FunctionComponent> = ({ childre }; const SparklineInteractiveHeaderStable = memo( - forwardRef( - ({ defaultLabel, defaultTitle, defaultSubHead, testID, trailing, labelNode }, forwardedRef) => { - const labelRef = useRef(null); - const titleRef = useRef(null); - const subHeadRef = useRef(null); - const subHeadIconRef = useRef(null); - const subHeadAccessoryRef = useRef(null); - - const valuesRef = useRef({ - title: defaultTitle, - label: defaultLabel, - subHead: defaultSubHead, - }); - - const styles = useSparklineInteractiveHeaderStyles(); - - const updateLabel = useCallback((label: string) => { - const prevLabel = valuesRef.current?.label; - - if (prevLabel !== label) { + ({ + ref: forwardedRef, + defaultLabel, + defaultTitle, + defaultSubHead, + testID, + trailing, + labelNode, + }: SparklineInteractiveHeaderMobileProps & { + ref?: React.Ref; + }) => { + const labelRef = useRef(null); + const titleRef = useRef(null); + const subHeadRef = useRef(null); + const subHeadIconRef = useRef(null); + const subHeadAccessoryRef = useRef(null); + + const valuesRef = useRef({ + title: defaultTitle, + label: defaultLabel, + subHead: defaultSubHead, + }); + + const styles = useSparklineInteractiveHeaderStyles(); + + const updateLabel = useCallback((label: string) => { + const prevLabel = valuesRef.current?.label; + + if (prevLabel !== label) { + // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. + // Usage in this component are known making this a high risk component. Contact team for more information. + + labelRef.current?.setNativeProps({ + text: label, + }); + valuesRef.current = { ...valuesRef.current, label }; + } + }, []); + + const updateTitle = useCallback( + (title: React.ReactNode) => { + const prevTitle = valuesRef.current?.title; + + if (prevTitle !== title && typeof title === 'string') { // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. // Usage in this component are known making this a high risk component. Contact team for more information. - labelRef.current?.setNativeProps({ - text: label, + titleRef.current?.setNativeProps({ + text: title, + style: styles.title(title), }); - valuesRef.current = { ...valuesRef.current, label }; + valuesRef.current = { ...valuesRef.current, title }; } - }, []); - - const updateTitle = useCallback( - (title: React.ReactNode) => { - const prevTitle = valuesRef.current?.title; - - if (prevTitle !== title && typeof title === 'string') { - // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. - // Usage in this component are known making this a high risk component. Contact team for more information. - - titleRef.current?.setNativeProps({ - text: title, - style: styles.title(title), - }); - valuesRef.current = { ...valuesRef.current, title }; - } - }, - [styles], - ); - - const updateSubHead = useCallback( - (subHead: SparklineInteractiveSubHead) => { - const prevSubHead = valuesRef.current?.subHead; - - if (prevSubHead !== subHead) { - // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. - // Usage in this component are known making this a high risk component. Contact team for more information. - - subHeadIconRef.current?.setNativeProps({ - text: subheadIconSignMap[subHead.sign], - style: styles.subHeadIcon(subHead.variant), - }); - // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. - // Usage in this component are known making this a high risk component. Contact team for more information. - - subHeadRef.current?.setNativeProps({ - text: interpolateSubHeadText(subHead), - style: styles.subHead(subHead.variant, subHead.accessoryText === undefined), - }); - // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. - // Usage in this component are known making this a high risk component. Contact team for more information. - - subHeadAccessoryRef.current?.setNativeProps({ - text: subHead.accessoryText ?? '', - style: styles.subHeadAccessory(), - }); - valuesRef.current = { ...valuesRef.current, subHead }; - } - }, - [styles], - ); - - // update is triggered from a parent component. - // We track the values of each input in a valuesRef object - // so that we can avoid updating unnecessarily if previous - // value is the same as the new value - const update = useCallback( - ({ label, title, subHead }: SparklineInteractiveHeaderValues) => { - if (label) { - updateLabel(label); - } - if (title) { - updateTitle(title); - } - if (subHead) { - updateSubHead(subHead); - } - }, - [updateLabel, updateSubHead, updateTitle], - ); - - useImperativeHandle(forwardedRef, () => { - return { - update, - }; - }, [update]); - - const label = !!defaultLabel && ( - - ); - - const title = ( - <> - - {typeof defaultTitle === 'string' ? ( - - ) : ( - defaultTitle - )} - - {!!defaultSubHead && ( - - + }, + [styles], + ); + + const updateSubHead = useCallback( + (subHead: SparklineInteractiveSubHead) => { + const prevSubHead = valuesRef.current?.subHead; + + if (prevSubHead !== subHead) { + // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. + // Usage in this component are known making this a high risk component. Contact team for more information. + + subHeadIconRef.current?.setNativeProps({ + text: subheadIconSignMap[subHead.sign], + style: styles.subHeadIcon(subHead.variant), + }); + // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. + // Usage in this component are known making this a high risk component. Contact team for more information. + + subHeadRef.current?.setNativeProps({ + text: interpolateSubHeadText(subHead), + style: styles.subHead(subHead.variant, subHead.accessoryText === undefined), + }); + // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. + // Usage in this component are known making this a high risk component. Contact team for more information. + + subHeadAccessoryRef.current?.setNativeProps({ + text: subHead.accessoryText ?? '', + style: styles.subHeadAccessory(), + }); + valuesRef.current = { ...valuesRef.current, subHead }; + } + }, + [styles], + ); + + // update is triggered from a parent component. + // We track the values of each input in a valuesRef object + // so that we can avoid updating unnecessarily if previous + // value is the same as the new value + const update = useCallback( + ({ label, title, subHead }: SparklineInteractiveHeaderValues) => { + if (label) { + updateLabel(label); + } + if (title) { + updateTitle(title); + } + if (subHead) { + updateSubHead(subHead); + } + }, + [updateLabel, updateSubHead, updateTitle], + ); + + useImperativeHandle(forwardedRef, () => { + return { + update, + }; + }, [update]); + + const label = !!defaultLabel && ( + + ); + + const title = ( + <> + + {typeof defaultTitle === 'string' ? ( + + ) : ( + defaultTitle + )} + + {!!defaultSubHead && ( + + + + {!!defaultSubHead.accessoryText && ( - {!!defaultSubHead.accessoryText && ( - - )} - - )} - - ); - - const trendA11yLabel = defaultSubHead - ? `${defaultSubHead?.variant === 'positive' ? 'up' : 'down'}` - : ''; - - const headerA11yLabel = `${defaultLabel}, ${defaultTitle}, ${trendA11yLabel} ${defaultSubHead?.priceChange}, ${defaultSubHead?.percent}`; - - return ( - - - {labelNode ?? label} - {title} - - {trailing} - - ); - }, - ), + )} + + )} + + ); + + const trendA11yLabel = defaultSubHead + ? `${defaultSubHead?.variant === 'positive' ? 'up' : 'down'}` + : ''; + + const headerA11yLabel = `${defaultLabel}, ${defaultTitle}, ${trendA11yLabel} ${defaultSubHead?.priceChange}, ${defaultSubHead?.percent}`; + + return ( + + + {labelNode ?? label} + {title} + + {trailing} + + ); + }, ); type SparklineInteractiveHeaderMobileProps = { @@ -320,23 +328,31 @@ type SparklineInteractiveHeaderMobileProps = { } & SparklineInteractiveHeaderProps; export const SparklineInteractiveHeader = memo( - forwardRef( - ({ defaultLabel, defaultTitle, defaultSubHead, testID, trailing, labelNode }, ref) => { - return ( - - ); - }, - ), + ({ + ref, + defaultLabel, + defaultTitle, + defaultSubHead, + testID, + trailing, + labelNode, + }: SparklineInteractiveHeaderMobileProps & { + ref?: React.Ref; + }) => { + return ( + + ); + }, ); diff --git a/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx index 027dc6c6da..8bb3efa32d 100644 --- a/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; +import React, { useImperativeHandle, useMemo, useRef } from 'react'; import { Animated, StyleSheet, TextInput } from 'react-native'; import type { ChartScrubParams } from '@coinbase/cds-common/types/Chart'; @@ -48,117 +48,113 @@ export type SparklineInteractiveHoverDateRefProps = { update: (params: ChartScrubParams) => void; }; -const SparklineInteractiveHoverDateWithGeneric = forwardRef( - ( - { formatHoverDate, shouldTakeUpHeight }: Props, - ref: React.ForwardedRef>, - ) => { - const theme = useTheme(); - const { hoverDateOpacity, gutter } = useSparklineInteractiveContext(); - const { SparklineInteractiveMinMaxLabelHeight, chartWidth } = useSparklineInteractiveConstants( - {}, - ); - const transform = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current; - const textInputRef = useRef(null); - - // period => number mapping - const measuredWidth = useRef>({}); - const measureIterations = useRef>({}); - // if we have no gutter the min/max label needs some space so it's not right up against the edge of the screen - const minGutter = gutter === 0 ? theme.space['1'] : 0; - - useImperativeHandle(ref, () => ({ - update: (params: ChartScrubParams) => { - const { - point: { x, date }, - period, - } = params; - - // the second conditional is to let typescript know x is always defined after this line - if (!Number.isFinite(x) || x === undefined) { - return; - } - - const text = formatHoverDate?.(date, period); - if (!text) { - return; - } - - // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. - // Usage in this component are known making this a high risk component. Contact team for more information. - - textInputRef.current?.setNativeProps({ - text: formatHoverDate?.(date, period), +const SparklineInteractiveHoverDateWithGeneric = ({ + ref, + formatHoverDate, + shouldTakeUpHeight, +}: Props & { + ref?: React.Ref>; +}) => { + const theme = useTheme(); + const { hoverDateOpacity, gutter } = useSparklineInteractiveContext(); + const { SparklineInteractiveMinMaxLabelHeight, chartWidth } = useSparklineInteractiveConstants( + {}, + ); + const transform = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current; + const textInputRef = useRef(null); + + // period => number mapping + const measuredWidth = useRef>({}); + const measureIterations = useRef>({}); + // if we have no gutter the min/max label needs some space so it's not right up against the edge of the screen + const minGutter = gutter === 0 ? theme.space['1'] : 0; + + useImperativeHandle(ref, () => ({ + update: (params: ChartScrubParams) => { + const { + point: { x, date }, + period, + } = params; + + // the second conditional is to let typescript know x is always defined after this line + if (!Number.isFinite(x) || x === undefined) { + return; + } + + const text = formatHoverDate?.(date, period); + if (!text) { + return; + } + + // BAD: We only disabled this lint rule to enable eslint upgrade after this component was implemented. These apis should never be used. + // Usage in this component are known making this a high risk component. Contact team for more information. + + textInputRef.current?.setNativeProps({ + text: formatHoverDate?.(date, period), + }); + + measureIterations.current[period] = measureIterations.current[period] ?? 0; + if (measureIterations.current[period] > MAX_MEASURE_ITERATIONS) { + const currWidth = measuredWidth.current[period]; + setTransform(x, currWidth, chartWidth, transform, minGutter); + } else { + textInputRef.current?.measure((ox, oy, width) => { + measureIterations.current[period] += 1; + measuredWidth.current[period] = Math.max(width, measuredWidth.current[period] ?? 0); + setTransform(x, measuredWidth.current[period], chartWidth, transform, minGutter); }); - - measureIterations.current[period] = measureIterations.current[period] ?? 0; - if (measureIterations.current[period] > MAX_MEASURE_ITERATIONS) { - const currWidth = measuredWidth.current[period]; - setTransform(x, currWidth, chartWidth, transform, minGutter); - } else { - textInputRef.current?.measure((ox, oy, width) => { - measureIterations.current[period] += 1; - measuredWidth.current[period] = Math.max(width, measuredWidth.current[period] ?? 0); - setTransform(x, measuredWidth.current[period], chartWidth, transform, minGutter); - }); - } - }, - })); - - const rootStyle = useMemo(() => { - return { - position: shouldTakeUpHeight ? 'relative' : 'absolute', - opacity: hoverDateOpacity, - backgroundColor: theme.color.bg, - height: SparklineInteractiveMinMaxLabelHeight, - ...styles.outer, - } as const; - }, [ - SparklineInteractiveMinMaxLabelHeight, - hoverDateOpacity, - shouldTakeUpHeight, - theme.color.bg, - ]); - - const innerStyle = useMemo(() => { - return { - ...styles.caption, - transform: transform.getTranslateTransform(), - }; - }, [transform]); - - const textInputStyle = useMemo(() => { - return { - fontSize: theme.fontSize.label2, - lineHeight: theme.lineHeight.label2, - fontFamily: theme.fontFamily.label2, - color: theme.color.fgMuted, - }; - }, [ - theme.color.fgMuted, - theme.fontFamily.label2, - theme.fontSize.label2, - theme.lineHeight.label2, - ]); - - return ( - - - - + } + }, + })); + + const rootStyle = useMemo(() => { + return { + position: shouldTakeUpHeight ? 'relative' : 'absolute', + opacity: hoverDateOpacity, + backgroundColor: theme.color.bg, + height: SparklineInteractiveMinMaxLabelHeight, + ...styles.outer, + } as const; + }, [SparklineInteractiveMinMaxLabelHeight, hoverDateOpacity, shouldTakeUpHeight, theme.color.bg]); + + const innerStyle = useMemo(() => { + return { + ...styles.caption, + transform: transform.getTranslateTransform(), + }; + }, [transform]); + + const textInputStyle = useMemo(() => { + return { + fontSize: theme.fontSize.label2, + lineHeight: theme.lineHeight.label2, + fontFamily: theme.fontFamily.label2, + color: theme.color.fgMuted, + }; + }, [ + theme.color.fgMuted, + theme.fontFamily.label2, + theme.fontSize.label2, + theme.lineHeight.label2, + ]); + + return ( + + + - ); - }, -); + + ); +}; -type ForwardRefWithPeriod = React.ForwardRefExoticComponent< - Props & { ref?: React.Ref> } ->; +type ForwardRefWithPeriod = ( + props: Props & { ref?: React.Ref> }, +) => React.ReactElement; export const SparklineInteractiveHoverDate = SparklineInteractiveHoverDateWithGeneric as ForwardRefWithPeriod; diff --git a/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx index 2273ffd54c..d7bb83a4b5 100644 --- a/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx @@ -26,8 +26,7 @@ export type SparklineInteractivePanGestureHandlerProps = children: React.ReactNode; }; -// Generics do not work with React.memo or forwardRef -// https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref/58473012 +// Generics do not work with React.memo export const SparklineInteractivePanGestureHandler = function SparklineInteractivePanGestureHandler< Period extends string, >({ From 2146eb05bdd069c1ac29d9901e75d1a54d6a771c Mon Sep 17 00:00:00 2001 From: "forge[bot]" <1548036+forge[bot]@users.noreply.ghe.com> Date: Thu, 4 Jun 2026 17:11:29 +0000 Subject: [PATCH 2/5] test(mobile): migrate UNSAFE_*ByType queries and fix a11y helper dedup Replace UNSAFE_*ByType(Component) reference-identity calls with role- and testID-based queries in Button, IconCounterButton, Text, and RollingNumber test files. Update isPressable/isText matchers in the accessibility helpers to dedup CDS wrapper nodes via findAll-length checks, matching the existing pattern in canBeDisabled. Add a stable testID to RollingNumber's screen-reader Text node to enable testID lookup. All 169 suites / 1828 tests pass; lint and typecheck clean. Co-authored-by: Erich Kuerschner --- packages/mobile/jest/accessibility/helpers.ts | 44 ++++++++++++++----- packages/mobile/jest/accessibility/rules.ts | 12 ++--- .../src/buttons/__tests__/Button.test.tsx | 15 ++++--- .../__tests__/IconCounterButton.test.tsx | 3 +- .../numbers/RollingNumber/RollingNumber.tsx | 1 + .../__tests__/RollingNumber.a11y.test.tsx | 8 ++-- .../src/typography/__tests__/Text.test.tsx | 2 +- 7 files changed, 55 insertions(+), 30 deletions(-) diff --git a/packages/mobile/jest/accessibility/helpers.ts b/packages/mobile/jest/accessibility/helpers.ts index 235d41e4d4..604e03778a 100644 --- a/packages/mobile/jest/accessibility/helpers.ts +++ b/packages/mobile/jest/accessibility/helpers.ts @@ -44,12 +44,11 @@ function getTypeName(type: ComponentType): string { } /** - * Check if a component type is a pressable element. - * Includes TouchableHighlight, TouchableOpacity, TouchableNativeFeedback, - * TouchableWithoutFeedback, and Pressable. + * Type-only check: whether a component type looks like a pressable element. + * Used internally; rules should use the node-based `isPressable` to avoid + * matching both a CDS wrapper and its inner native pressable in the same tree. */ -export function isPressable(type: ComponentType): boolean { - // Direct reference comparison for React component instances +function isPressableType(type: ComponentType): boolean { if ( type === TouchableHighlight || type === TouchableOpacity || @@ -60,25 +59,46 @@ export function isPressable(type: ComponentType): boolean { return true; } - // String name comparison for host components or named types const typeName = getTypeName(type); return PRESSABLE_TYPE_NAMES.some((name) => typeName.includes(name)); } /** - * Check if a component type is a Text element. + * Type-only check: whether a component type looks like a Text element. */ -export function isText(type: ComponentType): boolean { - // Direct reference comparison +function isTextType(type: ComponentType): boolean { if (type === Text) { return true; } - - // String name comparison const typeName = getTypeName(type); return typeName === 'Text'; } +/** + * Check if a node is a pressable element. Returns false for CDS wrapper + * components that contain a leaf pressable (so a single logical control + * is only matched once at the leaf). + */ +export function isPressable(node: TestInstance): boolean { + if (!isPressableType(node.type)) { + return false; + } + const pressablesInTree = node.findAll((n) => isPressableType(n.type)); + return pressablesInTree.length === 1; +} + +/** + * Check if a node is a Text element. Returns false for wrapper components + * that contain a leaf Text (e.g. CDS `Text` wrapping `RNText`). + */ +export function isText(node: TestInstance): boolean { + if (!isTextType(node.type)) { + return false; + } + const textsInTree = node.findAll((n) => isTextType(n.type)); + return textsInTree.length === 1; +} + /** * Check if a node is an adjustable component (Slider). * Returns false for wrapper components that contain a Slider. @@ -95,7 +115,7 @@ export function isAdjustable(node: TestInstance): boolean { * Check if a node is a checkbox (pressable with role="checkbox"). */ export function isCheckbox(node: TestInstance): boolean { - return isPressable(node.type) && node.props.accessibilityRole === 'checkbox'; + return isPressable(node) && node.props.accessibilityRole === 'checkbox'; } /** diff --git a/packages/mobile/jest/accessibility/rules.ts b/packages/mobile/jest/accessibility/rules.ts index bf5e372c89..c44e25edcf 100644 --- a/packages/mobile/jest/accessibility/rules.ts +++ b/packages/mobile/jest/accessibility/rules.ts @@ -48,7 +48,7 @@ const ALLOWED_CHECKED_VALUES_MESSAGE = ALLOWED_CHECKED_VALUES.join(' or '); */ const pressableRoleRequired: Rule = { id: 'pressable-role-required', - matcher: (node) => isPressable(node.type), + matcher: (node) => isPressable(node), assertion: (node) => ALLOWED_PRESSABLE_ROLES.includes(node.props.accessibilityRole), help: { problem: @@ -63,7 +63,7 @@ const pressableRoleRequired: Rule = { */ const pressableAccessibleRequired: Rule = { id: 'pressable-accessible-required', - matcher: (node) => isPressable(node.type), + matcher: (node) => isPressable(node), assertion: (node) => node.props.accessible !== false, help: { problem: 'This button is not accessible (selectable) to the user', @@ -78,7 +78,7 @@ const pressableAccessibleRequired: Rule = { */ const pressableLabelRequired: Rule = { id: 'pressable-label-required', - matcher: (node) => isPressable(node.type), + matcher: (node) => isPressable(node), assertion: (node) => { const textNode = findTextNode(node); const textContent = textNode?.props?.children; @@ -163,7 +163,7 @@ const adjustableValueRequired: Rule = { */ const linkRoleRequired: Rule = { id: 'link-role-required', - matcher: (node) => isText(node.type), + matcher: (node) => isText(node), assertion: (node) => { const { onPress, accessibilityRole } = node.props; if (onPress) { @@ -183,7 +183,7 @@ const linkRoleRequired: Rule = { */ const linkRoleMisused: Rule = { id: 'link-role-misused', - matcher: (node) => isText(node.type), + matcher: (node) => isText(node), assertion: (node) => { const { onPress, accessibilityRole } = node.props; if (!onPress) { @@ -203,7 +203,7 @@ const linkRoleMisused: Rule = { */ const noEmptyText: Rule = { id: 'no-empty-text', - matcher: (node) => isText(node.type), + matcher: (node) => isText(node), assertion: (node) => !!node.props?.children, help: { problem: "This text node doesn't contain text and so no accessibility label can be inferred", diff --git a/packages/mobile/src/buttons/__tests__/Button.test.tsx b/packages/mobile/src/buttons/__tests__/Button.test.tsx index 73bebbdceb..f350c9081f 100644 --- a/packages/mobile/src/buttons/__tests__/Button.test.tsx +++ b/packages/mobile/src/buttons/__tests__/Button.test.tsx @@ -188,11 +188,14 @@ describe('Button', () => { , ); - const text = screen.UNSAFE_getByType(Text); - expect(text.props.font).toBe('body'); - expect(text.props.fontFamily).toBe('title4'); - expect(text.props.fontSize).toBe('caption'); - expect(text.props.fontWeight).toBe('label1'); - expect(text.props.lineHeight).toBe('display3'); + const textWrapper = screen + .UNSAFE_getAllByProps({ testID: 'text-headline' }) + .find((node) => 'font' in node.props && 'fontFamily' in node.props); + expect(textWrapper).toBeTruthy(); + expect(textWrapper!.props.font).toBe('body'); + expect(textWrapper!.props.fontFamily).toBe('title4'); + expect(textWrapper!.props.fontSize).toBe('caption'); + expect(textWrapper!.props.fontWeight).toBe('label1'); + expect(textWrapper!.props.lineHeight).toBe('display3'); }); }); diff --git a/packages/mobile/src/buttons/__tests__/IconCounterButton.test.tsx b/packages/mobile/src/buttons/__tests__/IconCounterButton.test.tsx index 4e664bd577..0ca10953db 100644 --- a/packages/mobile/src/buttons/__tests__/IconCounterButton.test.tsx +++ b/packages/mobile/src/buttons/__tests__/IconCounterButton.test.tsx @@ -4,7 +4,6 @@ import React from 'react'; import { View } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; -import { Pressable } from '../../system'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { IconCounterButton } from '../IconCounterButton'; @@ -25,7 +24,7 @@ describe('IconCounterButton', () => { , ); - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); + expect(screen.getAllByRole('button')).toHaveLength(1); }); it('calls onPress when pressed', () => { diff --git a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx index 52bb6055e0..93481e4b01 100644 --- a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx +++ b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx @@ -704,6 +704,7 @@ export const RollingNumber = memo( accessibilityLiveRegion={accessibilityLiveRegion} importantForAccessibility="yes" style={[baseStylesheet.screenReaderOnly, styles?.text]} + testID="rolling-number-sr-only" {...textProps} > {`${accessibilityLabelPrefix ?? ''} diff --git a/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx index 847cc4e23d..658046e893 100644 --- a/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx +++ b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { render, screen } from '@testing-library/react-native'; -import { Text } from '../../typography/Text'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { RollingNumber } from '../RollingNumber/RollingNumber'; const getSrOnlyText = (live: 'polite' | 'assertive') => { - const nodes = screen.UNSAFE_queryAllByType(Text); - return nodes.find((n) => n.props.accessibilityLiveRegion === live) ?? null; + const node = screen.queryByTestId('rolling-number-sr-only'); + if (node && node.props.accessibilityLiveRegion === live) { + return node; + } + return null; }; const normalize = (s: unknown) => String(s).replace(/\s+/g, ' ').trim(); diff --git a/packages/mobile/src/typography/__tests__/Text.test.tsx b/packages/mobile/src/typography/__tests__/Text.test.tsx index d51a09f6bb..fb89bf4b2b 100644 --- a/packages/mobile/src/typography/__tests__/Text.test.tsx +++ b/packages/mobile/src/typography/__tests__/Text.test.tsx @@ -40,7 +40,7 @@ describe('Text', () => { { wrapper }, ); - expect(screen.UNSAFE_queryAllByType(Text)).toHaveLength(1); + expect(screen.getAllByTestId(`text-${fontName}`)).toHaveLength(1); expect(screen.getByText('Text')).toBeTruthy(); expect(screen.getByTestId(`text-${fontName}`)).toBeAccessible(); }); From 91f720769644ad133947d3efca45f7bdb312b64a Mon Sep 17 00:00:00 2001 From: "forge[bot]" <1548036+forge[bot]@users.noreply.ghe.com> Date: Thu, 4 Jun 2026 18:57:19 +0000 Subject: [PATCH 3/5] fix(mobile): address PR #151 review feedback - Add ref-forwarding tests for TextInput and ContentCard - Remove React.FC from Tour.tsx; use direct callable signatures - Widen TourStepArrowComponent type in common/useTour to accept ref-as-prop components without structural mismatch - Fix isText helper to only dedup when direct Text child shares the same onPress, accessibilityRole, and accessibilityLabel, preserving a11y violations on legitimate nested compositions - Remove shipped testID from RollingNumber SR-only Text node and update test to query via prop-based UNSAFE_queryAllByProps Co-authored-by: Erich Kuerschner --- packages/common/src/tour/useTour.ts | 9 +++++--- packages/mobile/jest/accessibility/helpers.ts | 21 +++++++++++++++---- .../__tests__/ContentCard.test.tsx | 15 ++++++++++++- .../src/controls/__tests__/TextInput.test.tsx | 20 +++++++++++++++++- .../numbers/RollingNumber/RollingNumber.tsx | 1 - .../__tests__/RollingNumber.a11y.test.tsx | 10 ++++----- packages/mobile/src/tour/Tour.tsx | 13 +++++++----- 7 files changed, 69 insertions(+), 20 deletions(-) diff --git a/packages/common/src/tour/useTour.ts b/packages/common/src/tour/useTour.ts index edb548be02..24a57c676d 100644 --- a/packages/common/src/tour/useTour.ts +++ b/packages/common/src/tour/useTour.ts @@ -47,12 +47,15 @@ export type TourStepArrowComponentProps = { /** * The TourStepArrowComponent forwards a ref to the underlying element. * This is required for the positioning library to work correctly. + * + * Accepts both the legacy `React.forwardRef`-produced component shape and the + * React 19 ref-as-prop callable form so consumers can pass either pattern. * @deprecated Import from `@coinbase/cds-web` or `@coinbase/cds-mobile` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v10 */ -export type TourStepArrowComponent = React.ForwardRefExoticComponent< - TourStepArrowComponentProps & { ref?: React.Ref } ->; +export type TourStepArrowComponent = + | React.ForwardRefExoticComponent }> + | ((props: TourStepArrowComponentProps & { ref?: React.Ref }) => React.ReactNode); export type TourStepComponent = React.FC>; diff --git a/packages/mobile/jest/accessibility/helpers.ts b/packages/mobile/jest/accessibility/helpers.ts index 604e03778a..8ba6628f96 100644 --- a/packages/mobile/jest/accessibility/helpers.ts +++ b/packages/mobile/jest/accessibility/helpers.ts @@ -88,15 +88,28 @@ export function isPressable(node: TestInstance): boolean { } /** - * Check if a node is a Text element. Returns false for wrapper components - * that contain a leaf Text (e.g. CDS `Text` wrapping `RNText`). + * Check if a node is a Text element. Filters out the CDS `Text` wrapper when + * it sits directly above its host `RNText` and forwards the same a11y-defining + * props verbatim, so a single logical Text is matched once at the host. Outer + * Text nodes that own distinct a11y semantics (e.g. `onPress`, `accessibilityRole`) + * are preserved so legitimate nested Text compositions still surface violations. */ export function isText(node: TestInstance): boolean { if (!isTextType(node.type)) { return false; } - const textsInTree = node.findAll((n) => isTextType(n.type)); - return textsInTree.length === 1; + const directChildText = node.children.find( + (c): c is TestInstance => typeof c !== 'string' && isTextType(c.type), + ); + if ( + directChildText && + directChildText.props.onPress === node.props.onPress && + directChildText.props.accessibilityRole === node.props.accessibilityRole && + directChildText.props.accessibilityLabel === node.props.accessibilityLabel + ) { + return false; + } + return true; } /** diff --git a/packages/mobile/src/cards/ContentCard/__tests__/ContentCard.test.tsx b/packages/mobile/src/cards/ContentCard/__tests__/ContentCard.test.tsx index 986ba04451..6fd141548b 100644 --- a/packages/mobile/src/cards/ContentCard/__tests__/ContentCard.test.tsx +++ b/packages/mobile/src/cards/ContentCard/__tests__/ContentCard.test.tsx @@ -1,4 +1,5 @@ -import { Text } from 'react-native'; +import { createRef } from 'react'; +import { Text, type View } from 'react-native'; import { NoopFn } from '@coinbase/cds-common/utils/mockUtils'; import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -40,6 +41,18 @@ describe('ContentCard', () => { ); expect(screen.getByTestId('content-card-test-id')).toBeTruthy(); }); + + it('forwards ref to the underlying View', () => { + const ref = createRef(); + render( + + + Test Content + + , + ); + expect(ref.current).not.toBeNull(); + }); }); describe('ContentCardHeader', () => { diff --git a/packages/mobile/src/controls/__tests__/TextInput.test.tsx b/packages/mobile/src/controls/__tests__/TextInput.test.tsx index f114c2f6e6..b34bc86825 100644 --- a/packages/mobile/src/controls/__tests__/TextInput.test.tsx +++ b/packages/mobile/src/controls/__tests__/TextInput.test.tsx @@ -1,4 +1,5 @@ -import { Animated, StyleSheet } from 'react-native'; +import { createRef } from 'react'; +import { Animated, StyleSheet, type TextInput as RNTextInput } from 'react-native'; import { focusedInputBorderWidth } from '@coinbase/cds-common/tokens/input'; import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -53,6 +54,23 @@ describe('TextInput', () => { expect(screen.getByTestId(testID)).toBeAccessible(); }); + it('forwards ref to the underlying RNTextInput', () => { + const testID = 'textinput-ref-test'; + const ref = createRef(); + render( + + + , + ); + expect(ref.current).not.toBeNull(); + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + it('renders a TextInput', () => { const testID = 'textinput-id'; const value = 'Example value'; diff --git a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx index 93481e4b01..52bb6055e0 100644 --- a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx +++ b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx @@ -704,7 +704,6 @@ export const RollingNumber = memo( accessibilityLiveRegion={accessibilityLiveRegion} importantForAccessibility="yes" style={[baseStylesheet.screenReaderOnly, styles?.text]} - testID="rolling-number-sr-only" {...textProps} > {`${accessibilityLabelPrefix ?? ''} diff --git a/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx index 658046e893..4b0b104705 100644 --- a/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx +++ b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx @@ -5,11 +5,11 @@ import { DefaultThemeProvider } from '../../utils/testHelpers'; import { RollingNumber } from '../RollingNumber/RollingNumber'; const getSrOnlyText = (live: 'polite' | 'assertive') => { - const node = screen.queryByTestId('rolling-number-sr-only'); - if (node && node.props.accessibilityLiveRegion === live) { - return node; - } - return null; + const candidates = screen.UNSAFE_queryAllByProps({ + accessibilityLiveRegion: live, + importantForAccessibility: 'yes', + }); + return candidates[0] ?? null; }; const normalize = (s: unknown) => String(s).replace(/\s+/g, ' ').trim(); diff --git a/packages/mobile/src/tour/Tour.tsx b/packages/mobile/src/tour/Tour.tsx index 74699742d4..e43d652cb9 100644 --- a/packages/mobile/src/tour/Tour.tsx +++ b/packages/mobile/src/tour/Tour.tsx @@ -47,9 +47,9 @@ export type TourStepArrowComponentProps = { }; // ------------ SUBCOMPONENT TYPES ------------ -export type TourStepArrowComponent = React.FC< - TourStepArrowComponentProps & { ref?: React.Ref } ->; +export type TourStepArrowComponent = ( + props: TourStepArrowComponentProps & { ref?: React.Ref }, +) => React.ReactNode; export type TourMaskComponentProps = { /** @@ -67,7 +67,7 @@ export type TourMaskComponentProps = { borderRadius?: string | number; }; -export type TourMaskComponent = React.FC; +export type TourMaskComponent = (props: TourMaskComponentProps) => React.ReactNode; export type TourBaseProps = SharedProps & TourOptions & @@ -153,7 +153,10 @@ const TourComponent = (_props: TourProps(null); const RenderedTourStep = activeTourStep?.Component; - // activeTourStep.ArrowComponent references old, deprecated type in cds-common + // activeTourStep.ArrowComponent comes from the platform-agnostic cds-common type whose + // TourStepArrowComponentProps (`style: Record`) is web-flavored; + // mobile's TourStepArrowComponentProps uses `StyleProp`. The cast is structurally + // unavoidable until cds-common's TourStepArrowComponentProps is split per platform. const RenderedTourStepArrow = (activeTourStep?.ArrowComponent as TourStepArrowComponent) ?? TourStepArrowComponent; From 3a5569a13aa21a9c752db2ef636b3ff2de71be93 Mon Sep 17 00:00:00 2001 From: "forge[bot]" <1548036+forge[bot]@users.noreply.ghe.com> Date: Thu, 4 Jun 2026 19:10:22 +0000 Subject: [PATCH 4/5] refactor(CDS-2123): revert common type widening; use local casts in mobile Tour Reverts the TourStepArrowComponent type change in packages/common so that packages/web (still on forwardRef) is not coupled to mobile's React 19 migration timing. packages/mobile/src/tour/Tour.tsx now handles the type mismatch via narrow `as unknown as TourStepArrowComponent` casts at the consumer boundary, with comments explaining the legacy forwardRef shape and the StyleProp vs Record divergence. All validation gates pass: mobile lint, typecheck, build, test (1830 passed), common typecheck, and web typecheck. Co-authored-by: Erich Kuerschner --- packages/common/src/tour/useTour.ts | 9 +++------ packages/mobile/src/tour/Tour.tsx | 11 ++++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/common/src/tour/useTour.ts b/packages/common/src/tour/useTour.ts index 24a57c676d..edb548be02 100644 --- a/packages/common/src/tour/useTour.ts +++ b/packages/common/src/tour/useTour.ts @@ -47,15 +47,12 @@ export type TourStepArrowComponentProps = { /** * The TourStepArrowComponent forwards a ref to the underlying element. * This is required for the positioning library to work correctly. - * - * Accepts both the legacy `React.forwardRef`-produced component shape and the - * React 19 ref-as-prop callable form so consumers can pass either pattern. * @deprecated Import from `@coinbase/cds-web` or `@coinbase/cds-mobile` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v10 */ -export type TourStepArrowComponent = - | React.ForwardRefExoticComponent }> - | ((props: TourStepArrowComponentProps & { ref?: React.Ref }) => React.ReactNode); +export type TourStepArrowComponent = React.ForwardRefExoticComponent< + TourStepArrowComponentProps & { ref?: React.Ref } +>; export type TourStepComponent = React.FC>; diff --git a/packages/mobile/src/tour/Tour.tsx b/packages/mobile/src/tour/Tour.tsx index e43d652cb9..219503bbd7 100644 --- a/packages/mobile/src/tour/Tour.tsx +++ b/packages/mobile/src/tour/Tour.tsx @@ -153,12 +153,13 @@ const TourComponent = (_props: TourProps(null); const RenderedTourStep = activeTourStep?.Component; - // activeTourStep.ArrowComponent comes from the platform-agnostic cds-common type whose - // TourStepArrowComponentProps (`style: Record`) is web-flavored; - // mobile's TourStepArrowComponentProps uses `StyleProp`. The cast is structurally - // unavoidable until cds-common's TourStepArrowComponentProps is split per platform. + // activeTourStep.ArrowComponent is typed by cds-common, which still uses the legacy + // `React.ForwardRefExoticComponent<…>` shape (kept intact because cds-web has not yet + // migrated off `React.forwardRef`). Mobile has migrated to React 19's ref-as-prop callable + // shape; runtime is equivalent under React 19. The cast also bridges the platform-agnostic + // style prop in common (`Record`) with mobile's `StyleProp`. const RenderedTourStepArrow = - (activeTourStep?.ArrowComponent as TourStepArrowComponent) ?? TourStepArrowComponent; + (activeTourStep?.ArrowComponent as unknown as TourStepArrowComponent) ?? TourStepArrowComponent; const [animation, animationApi] = useSpring( () => ({ from: { opacity: 0 }, config: springConfig.slow }), From ea8979a0f5ec9da61e4e4ab45d69d14af3723f43 Mon Sep 17 00:00:00 2001 From: "forge[bot]" <1548036+forge[bot]@users.noreply.ghe.com> Date: Thu, 4 Jun 2026 20:09:59 +0000 Subject: [PATCH 5/5] test(CDS-2123): replace UNSAFE_queryAllByProps with queryAllByText in RollingNumber a11y tests Use `screen.queryAllByText(/.+/)` filtered by `accessibilityLiveRegion` prop to locate the SR-only Text node, eliminating the last UNSAFE_* query in RollingNumber.a11y.test.tsx. Visible and measured-digit Text nodes are excluded by RNTL's default `includeHiddenElements: false` because they carry `accessibilityElementsHidden` / `importantForAccessibility="no-hide-descendants"`, leaving only the screen-reader Text for the `.find()` predicate to match. Co-authored-by: Erich Kuerschner --- .../src/numbers/__tests__/RollingNumber.a11y.test.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx index 4b0b104705..bf34305298 100644 --- a/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx +++ b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx @@ -5,11 +5,8 @@ import { DefaultThemeProvider } from '../../utils/testHelpers'; import { RollingNumber } from '../RollingNumber/RollingNumber'; const getSrOnlyText = (live: 'polite' | 'assertive') => { - const candidates = screen.UNSAFE_queryAllByProps({ - accessibilityLiveRegion: live, - importantForAccessibility: 'yes', - }); - return candidates[0] ?? null; + const candidates = screen.queryAllByText(/.+/); + return candidates.find((c) => c.props.accessibilityLiveRegion === live) ?? null; }; const normalize = (s: unknown) => String(s).replace(/\s+/g, ' ').trim();