diff --git a/.github/workflows/visreg-mobile.yml b/.github/workflows/visreg-mobile.yml index 5c149e1b04..f4c1289f59 100644 --- a/.github/workflows/visreg-mobile.yml +++ b/.github/workflows/visreg-mobile.yml @@ -128,11 +128,33 @@ jobs: - name: Boot iOS simulator run: | - STATE=$(xcrun simctl list devices available --json | jq -r '.devices[] | .[] | select(.name == "iPhone 16") | .state' | head -n 1) - if [ "$STATE" != "Booted" ]; then - xcrun simctl boot "iPhone 16" + # Use whichever iPhone simulator ships with the current macos-latest image + # rather than pinning a device by name. GitHub periodically rolls + # macos-latest to a new Xcode image with a different default device set, so + # a hard-coded name (e.g. "iPhone 16") eventually stops resolving and the + # boot fails with "Invalid device or device pair". Prefer an already-booted + # device, otherwise take the first available iPhone. + DEVICE=$(xcrun simctl list devices available --json | jq -r ' + [ .devices | to_entries[] | .key as $runtime | .value[] + | select(.name | startswith("iPhone")) + | { runtime: ($runtime | sub("com.apple.CoreSimulator.SimRuntime."; "")), name, udid, state } ] + | (map(select(.state == "Booted")) + .) + | .[0] // empty') + + if [ -z "$DEVICE" ]; then + echo "No available iPhone simulator found on this runner" >&2 + xcrun simctl list devices available + exit 1 fi - xcrun simctl bootstatus booted + + UDID=$(echo "$DEVICE" | jq -r '.udid') + LABEL=$(echo "$DEVICE" | jq -r '"\(.name) [\(.runtime)] (\(.udid))"') + + echo "Booting iOS simulator: $LABEL" + echo "iOS simulator: $LABEL" >> "$GITHUB_STEP_SUMMARY" + + # -b boots the device if it isn't already, then waits for full boot. + xcrun simctl bootstatus "$UDID" -b sleep 30 - name: Install app on simulator diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index e7fbf3bee9..5cebd83ef1 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.6.2 ((7/8/2026, 07:16 AM PST)) + +This is an artificial version bump with no new change. + ## 9.6.1 ((7/7/2026, 12:21 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index 31fb48ca9e..3ef71f03ac 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.6.1", + "version": "9.6.2", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 44080bee38..4b83000702 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.6.2 ((7/8/2026, 07:16 AM PST)) + +This is an artificial version bump with no new change. + ## 9.6.1 ((7/7/2026, 12:21 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 8a1ec6ff36..7a571c1829 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "9.6.1", + "version": "9.6.2", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 44f02640c8..5a150f27a0 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 9.6.2 (7/8/2026 PST) + +#### 🐞 Fixes + +- Chore: refactor from React.forwardRef to ref as prop for all cds-mobile components. [[#741](https://github.com/coinbase/cds/pull/741)] + ## 9.6.1 (7/7/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile/jest/accessibility/helpers.ts b/packages/mobile/jest/accessibility/helpers.ts index 235d41e4d4..8ba6628f96 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,59 @@ 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. 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 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; +} + /** * Check if a node is an adjustable component (Slider). * Returns false for wrapper components that contain a Slider. @@ -95,7 +128,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/package.json b/packages/mobile/package.json index 193ee76f3a..3debf925fc 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.6.1", + "version": "9.6.2", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/accordion/AccordionHeader.tsx b/packages/mobile/src/accordion/AccordionHeader.tsx index 6248d0dd40..1c58fdd8ed 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 { @@ -87,45 +87,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 9ac1c6ae39..1e1040a107 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 b7b1852498..d6bc54f540 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'; @@ -69,258 +61,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 b1055bf52c..83d581989b 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 4943233797..da473623e8 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/core/theme'; @@ -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 47c6290064..c88a6b7056 100644 --- a/packages/mobile/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/mobile/src/alpha/select/DefaultSelectControl.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, 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,362 +36,344 @@ type DefaultSelectControlComponent = < ) => React.ReactElement; export const DefaultSelectControlComponent = memo( - forwardRef( - ( - { - type, - options, - value, - onChange, - open, - placeholder, - disabled, - readOnly, - setOpen, - variant, - helperText, - label, - labelVariant: labelVariantProp, - contentNode, - startNode, - endNode: customEndNode, - compact, - align = 'start', - font = 'body', - labelColor = 'fg', - labelFont = 'label1', - bordered = true, - borderWidth = bordered ? 100 : 0, - focusedBorderWidth = bordered ? undefined : 200, - inputBackground = !disabled && readOnly ? 'bgSecondary' : 'bg', - borderRadius, - maxSelectedOptionsToShow = 3, - accessibilityHint, - 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, + readOnly, + setOpen, + variant, + helperText, + label, + labelVariant: labelVariantProp, + contentNode, + startNode, + endNode: customEndNode, + compact, + align = 'start', + font = 'body', + labelColor = 'fg', + labelFont = 'label1', + bordered = true, + borderWidth = bordered ? 100 : 0, + focusedBorderWidth = bordered ? undefined : 200, + inputBackground = !disabled && readOnly ? 'bgSecondary' : 'bg', + borderRadius, + maxSelectedOptionsToShow = 3, + accessibilityHint, + 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 isInteractionBlocked = disabled || readOnly; + const isInteractionBlocked = disabled || readOnly; - const handleToggleOpen = useCallback(() => { - if (isInteractionBlocked) return; - setOpen((currentOpen) => !currentOpen); - }, [isInteractionBlocked, setOpen]); + const handleToggleOpen = useCallback(() => { + if (isInteractionBlocked) return; + setOpen((currentOpen) => !currentOpen); + }, [isInteractionBlocked, setOpen]); - 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, - labelColor, - labelFont, - styles?.controlLabelNode, - ]); + return label; + }, [ + shouldShowInsideLabel, + shouldShowCompactLabel, + label, + labelColor, + labelFont, + 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, - labelColor, - labelFont, - styles?.controlLabelNode, - ]); + return label; + }, [ + shouldShowInsideLabel, + shouldShowCompactLabel, + label, + labelColor, + labelFont, + 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, - isInteractionBlocked, - ]); + } - // 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( - () => ( - + return typeof singleValueContent === 'string' ? ( + + {singleValueContent} + + ) : ( + singleValueContent + ); + }, [ + hasValue, + isMultiSelect, + singleValueContent, + font, + align, + value, + maxSelectedOptionsToShow, + valueAlignment, + hiddenSelectedOptionsLabel, + optionsMap, + removeSelectedOptionAccessibilityLabel, + disabled, + onChange, + isInteractionBlocked, + ]); + + // 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( + () => ( + + - - {!!startNode && ( - - {startNode} - - )} - {shouldShowCompactLabel ? ( - - {inlineLabelNode} - - ) : null} - {shouldShowInsideLabel ? ( - - {inlineLabelNode} - - {valueNode} - {contentNode} - - - ) : ( + {!!startNode && ( + + {startNode} + + )} + {shouldShowCompactLabel ? ( + + {inlineLabelNode} + + ) : null} + {shouldShowInsideLabel ? ( + + {inlineLabelNode} - )} - + + ) : ( + + {valueNode} + {contentNode} + + )} - - ), - [ - ref, - accessibilityHint, - computedControlAccessibilityLabel, - disabled, - onBlur, - onFocus, - styles?.controlInputNode, - styles?.controlStartNode, - styles?.controlValueNode, - startNode, - shouldShowCompactLabel, - shouldShowInsideLabel, - inlineLabelNode, - valueAlignment, - valueNode, - contentNode, - handleToggleOpen, - ], - ); + + + ), + [ + ref, + accessibilityHint, + computedControlAccessibilityLabel, + disabled, + onBlur, + onFocus, + styles?.controlInputNode, + styles?.controlStartNode, + styles?.controlValueNode, + startNode, + shouldShowCompactLabel, + shouldShowInsideLabel, + inlineLabelNode, + valueAlignment, + valueNode, + contentNode, + handleToggleOpen, + ], + ); - const endNode = useMemo( - () => ( - - - {customEndNode ? customEndNode : } - - - ), - [styles?.controlEndNode, disabled, customEndNode, open, handleToggleOpen], - ); + const endNode = useMemo( + () => ( + + + {customEndNode ? customEndNode : } + + + ), + [styles?.controlEndNode, disabled, customEndNode, open, handleToggleOpen], + ); - 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], - ); + 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 ( - - ); - }, - ), + 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 bfc16a1ca1..3264fbed63 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/Button'; @@ -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 b7a80f787d..7be998762b 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,202 +48,203 @@ 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, - readOnly, - 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, - borderWidth, - focusedBorderWidth, - inputBackground, - labelColor, - labelFont, - borderRadius, - SelectOptionComponent = DefaultSelectOption, - SelectAllOptionComponent = DefaultSelectAllOption, - SelectDropdownComponent = DefaultSelectDropdown, - SelectControlComponent = DefaultSelectControl, - SelectEmptyDropdownContentsComponent = DefaultSelectEmptyDropdownContents, - SelectOptionGroupComponent = DefaultSelectOptionGroup, - style, - styles, - testID, - } = 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, + readOnly, + 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, + borderWidth, + focusedBorderWidth, + inputBackground, + labelColor, + labelFont, + borderRadius, + 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 97e8930b3e..e851560c96 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 { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; @@ -109,82 +109,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 ccafa342e0..abc7ff3da2 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'; @@ -112,211 +112,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 121932c73a..282dca60f7 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 { ButtonVariant } from '@coinbase/cds-common/types/ButtonBaseProps'; @@ -87,172 +87,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 ec8711ffd9..2f29ed91b8 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 { IconButtonVariant } from '@coinbase/cds-common/types/IconButtonBaseProps'; @@ -50,7 +50,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, @@ -130,7 +135,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 d5de7875c4..3375e51fb4 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 } from '@coinbase/cds-common/types/IconSize'; @@ -42,68 +42,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 5cd924a4a6..b702da104b 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/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/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 da085180c8..c9a5708d6f 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 f3194bd796..49ab708265 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/SharedProps'; @@ -66,105 +66,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 bd76fc6079..71132201ea 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/SharedProps'; @@ -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 03e64c6e7a..1dc9123fb2 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/SharedProps'; @@ -44,94 +44,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/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/cards/MediaCard/index.tsx b/packages/mobile/src/cards/MediaCard/index.tsx index 1236b07844..be904a9e4d 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/core/theme'; @@ -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 71185113f9..8c5a111e76 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/core/theme'; @@ -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 3bdce76cb4..a86c0cb217 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, @@ -549,598 +548,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, @@ -1148,69 +1119,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 d435f0340f..4133e92f00 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'; @@ -15,92 +15,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 22751a8684..27fe2c80b6 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 9072a778d4..32233fd9e7 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 2f4f258745..4cb40de453 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'; @@ -48,71 +48,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 87ac7016ba..d6f0614994 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/types/SharedProps'; @@ -51,7 +51,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, @@ -112,5 +117,5 @@ export const Coachmark = memo( ); - }), + }, ); diff --git a/packages/mobile/src/collapsible/Collapsible.tsx b/packages/mobile/src/collapsible/Collapsible.tsx index 36449e08de..b75e71ee1b 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, { @@ -58,7 +58,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, @@ -202,7 +207,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 742c119535..d7cef2223b 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/core/theme'; @@ -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 b9dc13cc6f..b08ccc4c4f 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, @@ -49,10 +49,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, @@ -254,9 +256,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 c2abc0d576..8d83b470ad 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/types/SharedProps'; 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 16b102804d..a06546b07c 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, @@ -99,10 +99,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, @@ -312,11 +314,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 66535e5a65..081f09d128 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/types/SharedProps'; import { isDevelopment } from '@coinbase/cds-utils'; @@ -35,10 +35,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, @@ -87,12 +92,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 1275a8fc87..6ae80997d9 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 } from '@coinbase/cds-common/types/IconButtonBaseProps'; import type { InputVariant } from '@coinbase/cds-common/types/InputBaseProps'; @@ -25,32 +25,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 9e0a5e1ffe..711f0f0937 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 1f1fa96193..c2c8d73464 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/core/theme'; @@ -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 55cf034cc0..a278f676d9 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, @@ -48,10 +48,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, @@ -250,9 +252,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 653341c8e6..9717654b2b 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/types/SharedProps'; 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 c38e3e08cb..3680f0e216 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 3d3c0a27eb..8aaf0bf3e6 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'; @@ -66,200 +66,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 b2c73f4339..59cd91c854 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'; @@ -119,10 +119,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(); @@ -156,7 +158,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 e5e5778287..b4fe480f09 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, @@ -129,7 +128,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, @@ -381,5 +385,5 @@ export const TextInput = memo( width={width} /> ); - }), + }, ); 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/dates/Calendar.tsx b/packages/mobile/src/dates/Calendar.tsx index 38093d932f..657d70a0e7 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 6254e485a2..1150c91128 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, @@ -110,7 +110,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, @@ -312,7 +317,7 @@ export const DatePicker = memo( )} ); - }), + }, ); DatePicker.displayName = 'DatePicker'; diff --git a/packages/mobile/src/dates/__tests__/Calendar.test.tsx b/packages/mobile/src/dates/__tests__/Calendar.test.tsx index 239f2306ab..293a4e8a92 100644 --- a/packages/mobile/src/dates/__tests__/Calendar.test.tsx +++ b/packages/mobile/src/dates/__tests__/Calendar.test.tsx @@ -1,7 +1,8 @@ +import { createRef } from 'react'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { DefaultThemeProvider } from '../../utils/testHelpers'; -import type { CalendarProps } from '../Calendar'; +import type { CalendarProps, CalendarRefHandle } from '../Calendar'; import { Calendar } from '../Calendar'; const testID = 'test-calendar'; @@ -376,4 +377,19 @@ describe('Calendar', () => { const calendar = screen.getByTestId(testID); expect(calendar).toHaveStyle({ backgroundColor: rootBackgroundColor }); }); + + it('exposes imperative focusInitialDate via ref', () => { + const ref = createRef(); + const seedDate = new Date(2024, 6, 15); + + render( + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.focusInitialDate).toBe('function'); + expect(() => ref.current?.focusInitialDate()).not.toThrow(); + }); }); diff --git a/packages/mobile/src/examples/ExampleScreen.tsx b/packages/mobile/src/examples/ExampleScreen.tsx index 540e941260..3a8e1b6885 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 847a647fde..eb82790603 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 { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { PinningDirection } from '@coinbase/cds-common/types/BoxBaseProps'; @@ -135,23 +135,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, @@ -168,6 +335,8 @@ export const Box = memo( color, background, borderColor, + borderWidth, + borderRadius, borderTopLeftRadius, borderTopRightRadius, borderBottomLeftRadius, @@ -177,13 +346,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, @@ -219,193 +385,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 ce93c91e44..f82c6252a5 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 400e5759ee..197d717a62 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 4295a57abb..e728ab3360 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'; @@ -48,67 +40,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/motion/__tests__/ColorSurge.test.tsx b/packages/mobile/src/motion/__tests__/ColorSurge.test.tsx new file mode 100644 index 0000000000..c1a7a5a129 --- /dev/null +++ b/packages/mobile/src/motion/__tests__/ColorSurge.test.tsx @@ -0,0 +1,74 @@ +import { createRef } from 'react'; +import { Animated } from 'react-native'; +import { act, render } from '@testing-library/react-native'; + +import { DefaultThemeProvider } from '../../utils/testHelpers'; +import type { ColorSurgeRefBaseProps } from '../ColorSurge'; +import { ColorSurge } from '../ColorSurge'; + +describe('ColorSurge', () => { + let start = jest.fn(); + + beforeEach(() => { + start = jest.fn(); + // @ts-expect-error - mock is incomplete but functional + jest.spyOn(Animated, 'sequence').mockImplementation(() => ({ start })); + // @ts-expect-error - mock is incomplete but functional + jest.spyOn(Animated, 'Value').mockImplementation(() => ({ + stopAnimation: jest.fn(), + setValue: jest.fn(), + interpolate: jest.fn(), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('starts animation on mount by default', () => { + render( + + + , + ); + expect(start).toHaveBeenCalledTimes(1); + }); + + it('does not start animation on mount when disableAnimateOnMount', () => { + render( + + + , + ); + expect(start).not.toHaveBeenCalled(); + }); + + it('exposes imperative play handler via ref', () => { + const ref = createRef(); + render( + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.play).toBe('function'); + + start.mockClear(); + act(() => void ref.current?.play()); + expect(start).toHaveBeenCalledTimes(1); + }); + + it('exposes imperative play handler that accepts a background color', () => { + const ref = createRef(); + render( + + + , + ); + + start.mockClear(); + act(() => void ref.current?.play('bgSecondary')); + expect(start).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/mobile/src/multi-content-module/MultiContentModule.tsx b/packages/mobile/src/multi-content-module/MultiContentModule.tsx index 1191d001c1..4e2bd8a62c 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/IllustrationNames'; @@ -32,74 +32,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 b789796fe9..af4d3ed89c 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/numbers/__tests__/RollingNumber.a11y.test.tsx b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx index 847cc4e23d..bf34305298 100644 --- a/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx +++ b/packages/mobile/src/numbers/__tests__/RollingNumber.a11y.test.tsx @@ -1,13 +1,12 @@ 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 candidates = screen.queryAllByText(/.+/); + return candidates.find((c) => c.props.accessibilityLiveRegion === live) ?? null; }; const normalize = (s: unknown) => String(s).replace(/\s+/g, ' ').trim(); diff --git a/packages/mobile/src/numpad/Numpad.tsx b/packages/mobile/src/numpad/Numpad.tsx index a0c0ad738c..33bc03abb4 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/types/SharedProps'; @@ -73,7 +73,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 = '.', @@ -146,7 +151,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 3e612588e4..a3838e21c0 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 } from '@coinbase/cds-common/types/ButtonBaseProps'; import type { IllustrationPictogramNames } from '@coinbase/cds-common/types/IllustrationNames'; @@ -60,7 +60,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, @@ -199,5 +204,5 @@ export const Alert = memo( ); - }), + }, ); diff --git a/packages/mobile/src/overlays/Toast.tsx b/packages/mobile/src/overlays/Toast.tsx index de03011dce..e95299e4bc 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, @@ -21,7 +21,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, @@ -128,5 +133,5 @@ export const Toast = memo( ); - }), + }, ); diff --git a/packages/mobile/src/overlays/__tests__/Alert.test.tsx b/packages/mobile/src/overlays/__tests__/Alert.test.tsx index fb7f5ee48b..fe49fbced0 100644 --- a/packages/mobile/src/overlays/__tests__/Alert.test.tsx +++ b/packages/mobile/src/overlays/__tests__/Alert.test.tsx @@ -1,10 +1,11 @@ -import { useCallback, useState } from 'react'; +import { createRef, useCallback, useState } from 'react'; import { Animated, Modal as RNModal } from 'react-native'; import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { Button } from '../../buttons'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Alert, type AlertBaseProps } from '../Alert'; +import type { ModalRefBaseProps } from '../modal/Modal'; const MockAlert = ({ visible: externalVisible, @@ -204,4 +205,32 @@ describe('Alert', () => { expect(onRequestClose).toHaveBeenCalledTimes(1); }); }); + + it('exposes imperative onRequestClose via ref', () => { + const ref = createRef(); + const onRequestClose = jest.fn(); + + render( + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.onRequestClose).toBe('function'); + + act(() => { + ref.current?.onRequestClose?.(); + }); + + expect(animationParallelSpy).toHaveBeenCalled(); + }); }); diff --git a/packages/mobile/src/overlays/__tests__/Toast.test.tsx b/packages/mobile/src/overlays/__tests__/Toast.test.tsx index 57aace5792..13fbff9f99 100644 --- a/packages/mobile/src/overlays/__tests__/Toast.test.tsx +++ b/packages/mobile/src/overlays/__tests__/Toast.test.tsx @@ -1,5 +1,7 @@ +import { createRef } from 'react'; import { Animated } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import type { ToastRefHandle } from '@coinbase/cds-common/overlays/ToastProvider'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { DefaultThemeProvider, SAFE_AREA_METRICS } from '../../utils/testHelpers'; @@ -87,4 +89,25 @@ describe('Toast', () => { expect(animationParallelSpy).toHaveBeenCalled(); expect(animationTimingSpy).toHaveBeenCalled(); }); + + it('exposes imperative hide handler via ref', () => { + const ref = createRef(); + const onWillHide = jest.fn(); + const onDidHide = jest.fn(); + + render( + + + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.hide).toBe('function'); + + ref.current?.hide(); + expect(onWillHide).toHaveBeenCalledTimes(1); + expect(onDidHide).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/mobile/src/overlays/drawer/Drawer.tsx b/packages/mobile/src/overlays/drawer/Drawer.tsx index 9ef393227e..84262d440a 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, @@ -136,7 +135,12 @@ const overlayContentContextValue: OverlayContentContextValue = { }; export const Drawer = memo( - forwardRef((_props, ref) => { + ({ + ref, + ..._props + }: DrawerProps & { + ref?: React.Ref; + }) => { const mergedProps = useComponentConfig('Drawer', _props); const { children, @@ -365,5 +369,5 @@ export const Drawer = memo( ); - }), + }, ); diff --git a/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx b/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx index 5fded9f3b6..be9f646bae 100644 --- a/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx +++ b/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { createRef, useCallback, useState } from 'react'; import { Modal } from 'react-native'; import { initialWindowMetrics, SafeAreaProvider } from 'react-native-safe-area-context'; import { drawerAnimationDefaultDuration } from '@coinbase/cds-common/animation/drawer'; @@ -11,7 +11,7 @@ import { Button } from '../../../buttons'; import { VStack } from '../../../layout/VStack'; import { Text } from '../../../typography/Text'; import { DefaultThemeProvider, SAFE_AREA_METRICS } from '../../../utils/testHelpers'; -import type { DrawerBaseProps } from '../Drawer'; +import type { DrawerBaseProps, DrawerRefBaseProps } from '../Drawer'; import { Drawer } from '../Drawer'; // We're using the drawers animation time here just to be extra close to the implementation @@ -177,4 +177,25 @@ describe('Drawer', () => { await waitFor(() => expect(onCloseComplete).toHaveBeenCalledTimes(1)); }); }); + + it('exposes imperative handleClose via ref', async () => { + const ref = createRef(); + const onCloseComplete = jest.fn(); + + render( + + + + {() => Drawer content} + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.handleClose).toBe('function'); + + ref.current?.handleClose(); + await waitFor(() => expect(onCloseComplete).toHaveBeenCalledTimes(1)); + }); }); diff --git a/packages/mobile/src/overlays/modal/Modal.tsx b/packages/mobile/src/overlays/modal/Modal.tsx index feae404c35..a53702f731 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, StyleProp, ViewStyle } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -99,7 +91,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 { @@ -248,7 +245,7 @@ export const Modal = memo( ); - }), + }, ); const styles = StyleSheet.create({ diff --git a/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx b/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx index cabae47fb2..da8edcb4ab 100644 --- a/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx +++ b/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { createRef, useCallback, useState } from 'react'; import { Animated, Modal as RNModal, StyleSheet } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum'; @@ -8,7 +8,7 @@ import type { ButtonProps } from '../../../buttons'; import { Button } from '../../../buttons'; import { Text } from '../../../typography/Text'; import { DefaultThemeProvider, SAFE_AREA_METRICS } from '../../../utils/testHelpers'; -import { Modal } from '../Modal'; +import { Modal, type ModalRefBaseProps } from '../Modal'; import { ModalBody } from '../ModalBody'; import { ModalFooter } from '../ModalFooter'; import { ModalHeader, type ModalHeaderProps } from '../ModalHeader'; @@ -404,4 +404,25 @@ describe('Modal', () => { expect(hasForwardedStyle).toBe(true); }); + + it('exposes imperative onRequestClose via ref', () => { + const ref = createRef(); + const onRequestClose = jest.fn(); + + render( + + + + + + Modal content + + + + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.onRequestClose).toBe('function'); + }); }); diff --git a/packages/mobile/src/overlays/tray/Tray.tsx b/packages/mobile/src/overlays/tray/Tray.tsx index e00ea91e96..4f9a6818b1 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, @@ -71,129 +70,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 fb1eebc830..d44333cfc1 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 7bd9852eb2..9100896d68 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 f272e6da72..70b3add65b 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/types/SectionHeaderProps'; import type { IconName } from '@coinbase/cds-icons'; @@ -8,61 +8,59 @@ import { HStack } from '../layout/HStack'; import { VStack } from '../layout/VStack'; 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 e31e984471..7476d24d41 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 be74202da4..3f1aaf12d5 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/Box'; @@ -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 498062bc87..755fe10754 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, @@ -58,275 +58,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 7f1abd43af..7b3c140b17 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 34ddc8359a..07b46c2faf 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 b169480b0e..46b8f7d17f 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'; @@ -36,30 +36,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 a754d1eab5..0592510168 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 1268cef2f9..e1fd0be350 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 } from '@coinbase/cds-common/types/SharedAccessibilityProps'; @@ -113,158 +113,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 40618e75f6..48e3886aca 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, @@ -121,125 +121,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/tabs/__tests__/Tabs.test.tsx b/packages/mobile/src/tabs/__tests__/Tabs.test.tsx index eb4b9b6c34..6dc93a7fa0 100644 --- a/packages/mobile/src/tabs/__tests__/Tabs.test.tsx +++ b/packages/mobile/src/tabs/__tests__/Tabs.test.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { StyleSheet, Text as RNText } from 'react-native'; +import React, { createRef, useState } from 'react'; +import { StyleSheet, Text as RNText, View } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; @@ -75,4 +75,23 @@ describe('Tabs', () => { const flat = StyleSheet.flatten(screen.getByTestId('tab-a').props.style); expect(flat.marginTop).toBe(marginTop); }); + + it('forwards ref to the underlying View', () => { + const ref = createRef(); + const tabs = [{ id: 'a', label: 'A', testID: 'tab-a' }]; + + const Wrapper = () => { + const [active, setActive] = useState<(typeof tabs)[number] | null>(tabs[0]); + return ( + + + + ); + }; + + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current).toBeInstanceOf(View); + }); }); diff --git a/packages/mobile/src/tag/Tag.tsx b/packages/mobile/src/tag/Tag.tsx index 48b368227a..111e7d4d1a 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 95d9e46ef7..904406d3f7 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.ForwardRefExoticComponent< - 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,9 +153,13 @@ const TourComponent = (_props: TourProps(null); const RenderedTourStep = activeTourStep?.Component; - // activeTourStep.ArrowComponent references old, deprecated type in cds-common + // 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 }), 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/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(); }); diff --git a/packages/mobile/src/typography/index.ts b/packages/mobile/src/typography/index.ts index 4829abd638..2c24f17221 100644 --- a/packages/mobile/src/typography/index.ts +++ b/packages/mobile/src/typography/index.ts @@ -1,11 +1,11 @@ export * from './Link'; export * from './Text'; export * from './TextBody'; -export * from './TextFallback'; export * from './TextCaption'; export * from './TextDisplay1'; export * from './TextDisplay2'; export * from './TextDisplay3'; +export * from './TextFallback'; export * from './TextHeadline'; export * from './TextLabel1'; export * from './TextLabel2'; diff --git a/packages/mobile/src/visualizations/ProgressBar.tsx b/packages/mobile/src/visualizations/ProgressBar.tsx index 06d0511367..6d0e016d50 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, @@ -61,7 +61,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', @@ -169,5 +174,5 @@ export const ProgressBar = memo( /> ); - }), + }, ); diff --git a/packages/mobile/src/visualizations/ProgressCircle.tsx b/packages/mobile/src/visualizations/ProgressCircle.tsx index 4f04f3329a..aa344c0c0b 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'; @@ -152,7 +152,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, @@ -301,7 +306,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 e4cc34beb0..1566fb37ae 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/types/SharedProps'; @@ -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 0dda490828..0ba17d6b6d 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/Rect'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; @@ -199,467 +199,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 22ae6dd8c9..f011b54232 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'; @@ -94,40 +94,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; @@ -137,31 +148,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 0161d3153b..c3152e9b33 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 } from '../axis/XAxis'; @@ -104,114 +104,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, @@ -219,59 +246,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 fd826e8066..476d25ebd3 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 } from '../axis/XAxis'; @@ -91,147 +91,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 c2b7a3b28d..080a6908a6 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/Box'; @@ -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 5ea488e5ce..3f5479a0af 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'; @@ -97,147 +97,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 21a99abfe3..9fcc470d35 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, @@ -256,234 +249,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 456fb65743..dd5d971cb7 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'; @@ -170,82 +170,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 7c918deb02..79832e6a8e 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'; @@ -55,158 +55,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 db1954c9da..7f27698043 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'; @@ -118,199 +118,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 = { @@ -321,23 +329,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, >({ diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 711f455653..1f8019334e 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.6.2 ((7/8/2026, 07:16 AM PST)) + +This is an artificial version bump with no new change. + ## 9.6.1 ((7/7/2026, 12:21 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/web/package.json b/packages/web/package.json index e946045204..e16c41429b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "9.6.1", + "version": "9.6.2", "description": "Coinbase Design System - Web", "repository": { "type": "git",