From 2abe890f2152cf4cc43f9bb0ef47f3cf5487934c Mon Sep 17 00:00:00 2001 From: Boba Date: Wed, 28 Jan 2026 16:59:16 -0800 Subject: [PATCH 1/4] feat: consolidate border props in Cell component --- packages/mobile/src/cells/Cell.tsx | 68 ++++++- .../cells/__stories__/ListCell.stories.tsx | 143 +++++++++++++++ packages/web/src/cells/Cell.tsx | 65 ++++++- .../cells/__stories__/ListCell.stories.tsx | 171 ++++++++++++++++++ packages/web/src/styles/styleProps.ts | 4 +- packages/web/src/system/Interactable.tsx | 34 +++- .../system/__tests__/Interactable.test.tsx | 66 +++++++ 7 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 packages/web/src/system/__tests__/Interactable.test.tsx diff --git a/packages/mobile/src/cells/Cell.tsx b/packages/mobile/src/cells/Cell.tsx index acf7ab11d..1158a4a8b 100644 --- a/packages/mobile/src/cells/Cell.tsx +++ b/packages/mobile/src/cells/Cell.tsx @@ -104,7 +104,24 @@ export const Cell = memo(function Cell({ accessory, accessoryNode, alignItems = 'center', + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, borderRadius = 200, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, children, styles, end, @@ -143,9 +160,52 @@ export const Cell = memo(function Cell({ const { marginX: innerSpacingMarginX, ...innerSpacingWithoutMarginX } = innerSpacing; + const borderProps = useMemo( + () => ({ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + }), + [ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + ], + ); + const content = useMemo(() => { const contentContainerProps = { - borderRadius, + ...borderProps, testID, renderToHardwareTextureAndroid: disabled, ...(selected ? { background } : {}), @@ -233,7 +293,7 @@ export const Cell = memo(function Cell({ ); }, [ - borderRadius, + borderProps, testID, disabled, selected, @@ -281,7 +341,7 @@ export const Cell = memo(function Cell({ accessibilityState={{ disabled, ...accessibilityState }} background="bg" blendStyles={blendStyles} - borderRadius={borderRadius} + {...borderProps} contentStyle={pressStyles} disabled={disabled} onPress={onPress} @@ -304,7 +364,7 @@ export const Cell = memo(function Cell({ styles?.pressable, accessibilityState, blendStyles, - borderRadius, + borderProps, ]); return ( diff --git a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx index cb9d29d7f..8d6b44754 100644 --- a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx @@ -794,6 +794,146 @@ const WithHelperText = () => ( ); +const BorderCustomization = () => { + const [isCondensed, setIsCondensed] = useState(true); + const spacingVariant = isCondensed ? 'condensed' : 'normal'; + + return ( + + setIsCondensed(Boolean(nextChecked))} + > + Spacing variant: {spacingVariant} + + + + + + + + + + + + + + + + + + + + + ); +}; + const CustomSpacing = () => ( <> { + + + diff --git a/packages/web/src/cells/Cell.tsx b/packages/web/src/cells/Cell.tsx index c03b99581..7c05823db 100644 --- a/packages/web/src/cells/Cell.tsx +++ b/packages/web/src/cells/Cell.tsx @@ -198,7 +198,24 @@ export const Cell: CellComponent = memo( accessory, accessoryNode, alignItems = 'center', + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, borderRadius = 200, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, children, style, styles, @@ -255,11 +272,53 @@ export const Cell: CellComponent = memo( const isButton = Boolean(onClick ?? onKeyDown ?? onKeyUp); const linkable = isAnchor || isButton; const contentTruncationStyle = cx(baseCss, shouldTruncate && truncationCss); + const borderProps = useMemo( + () => ({ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + }), + [ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + ], + ); const content = useMemo(() => { // props for the entire inner container that wraps the top content // (media, children, intermediary, detail, accessory) and the bottom content const contentContainerProps = { - borderRadius, + ...borderProps, className: cx(contentClassName, classNames?.contentContainer), testID, ...(selected ? { background } : {}), @@ -361,7 +420,7 @@ export const Cell: CellComponent = memo( ); }, [ - borderRadius, + borderProps, contentClassName, classNames?.contentContainer, classNames?.topContent, @@ -410,7 +469,7 @@ export const Cell: CellComponent = memo( accessibilityLabel, accessibilityLabelledBy, background: 'bg' as const, - borderRadius, + ...borderProps, className: cx(pressCss, insetFocusRingCss, classNames?.pressable), disabled, marginX: innerSpacingMarginX, diff --git a/packages/web/src/cells/__stories__/ListCell.stories.tsx b/packages/web/src/cells/__stories__/ListCell.stories.tsx index 67d664f5a..c10b6f820 100644 --- a/packages/web/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/web/src/cells/__stories__/ListCell.stories.tsx @@ -883,6 +883,176 @@ const WithHelperText = () => ( ); +const BorderCustomization = () => { + const [isCondensed, setIsCondensed] = useState(true); + const spacingVariant = isCondensed ? 'condensed' : 'normal'; + + return ( + + setIsCondensed(event.currentTarget.checked)} + > + Spacing variant: {spacingVariant} + + + + + + + + + + + + + + + + + + + + + ); +}; + const SpacingVariant = () => ( {/* Preferred (new design) */} @@ -1233,6 +1403,7 @@ const UseCaseShowcase = () => { }; export { + BorderCustomization, CompactContentDeprecated, CompactPressableContentDeprecated, CondensedListCell, diff --git a/packages/web/src/styles/styleProps.ts b/packages/web/src/styles/styleProps.ts index 54457e755..7c772abfb 100644 --- a/packages/web/src/styles/styleProps.ts +++ b/packages/web/src/styles/styleProps.ts @@ -150,7 +150,9 @@ export const dynamicPixelProps = { flexBasis: 1, } as const satisfies Partial>; -export type ResponsiveProp = T | { base?: T; phone?: T; tablet?: T; desktop?: T }; +export type ResponsiveValue = { base?: T; phone?: T; tablet?: T; desktop?: T }; + +export type ResponsiveProp = T | ResponsiveValue; export type ResponsiveProps = { [key in keyof T]?: ResponsiveProp; diff --git a/packages/web/src/system/Interactable.tsx b/packages/web/src/system/Interactable.tsx index 3876bfbb8..eb50c5371 100644 --- a/packages/web/src/system/Interactable.tsx +++ b/packages/web/src/system/Interactable.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useMemo } from 'react'; +import React, { forwardRef, useContext, useMemo } from 'react'; import { getBlendedColor } from '@coinbase/cds-common/color/getBlendedColor'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { @@ -14,6 +14,8 @@ import type { Theme } from '../core/theme'; import { cx } from '../cx'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxBaseProps } from '../layout/Box'; +import { media } from '../styles/media'; +import type { ResponsiveProp, ResponsiveValue } from '../styles/styleProps'; import { interactableBackground, @@ -27,6 +29,7 @@ import { interactablePressedBorderColor, interactablePressedOpacity, } from './interactableCSSProperties'; +import { MediaQueryContext } from './MediaQueryProvider'; const COMPONENT_STATIC_CLASSNAME = 'cds-Interactable'; @@ -106,6 +109,24 @@ const transparentWhileInactiveCss = css` } `; +const isResponsiveValue = (value: ResponsiveProp): value is ResponsiveValue => + typeof value === 'object' && + value !== null && + ('base' in value || 'phone' in value || 'tablet' in value || 'desktop' in value); + +const resolveResponsiveProp = ( + value: ResponsiveProp | undefined, + getSnapshot?: (query: string) => boolean, +): T | undefined => { + if (!value || !isResponsiveValue(value)) return value; + const fallback = value.base ?? value.phone ?? value.tablet ?? value.desktop; + if (!getSnapshot) return fallback; + if (typeof value.phone !== 'undefined' && getSnapshot(media.phone)) return value.phone; + if (typeof value.tablet !== 'undefined' && getSnapshot(media.tablet)) return value.tablet; + if (typeof value.desktop !== 'undefined' && getSnapshot(media.desktop)) return value.desktop; + return fallback; +}; + export const interactableDefaultElement = 'button'; export type InteractableDefaultElement = typeof interactableDefaultElement; @@ -163,8 +184,6 @@ export type InteractableBaseProps = Polymorphic.ExtendableProps< background?: ThemeVars.Color; /** Set element to block and expand to 100% width. */ block?: boolean; - /** Border color of the element. */ - borderColor?: ThemeVars.Color; /** Is the element currently disabled. */ disabled?: boolean; /** @@ -223,6 +242,11 @@ export const Interactable: InteractableComponent = forwardRef< ) => { const Component = (as ?? interactableDefaultElement) satisfies React.ElementType; const theme = useTheme(); + const mediaQueryContext = useContext(MediaQueryContext); + const resolvedBorderColor = useMemo( + () => resolveResponsiveProp(borderColor, mediaQueryContext?.getSnapshot), + [borderColor, mediaQueryContext?.getSnapshot], + ); const interactableStyle = useMemo( () => ({ @@ -230,11 +254,11 @@ export const Interactable: InteractableComponent = forwardRef< theme, background, blendStyles, - borderColor, + borderColor: resolvedBorderColor, }), ...style, }), - [style, background, theme, blendStyles, borderColor], + [style, background, theme, blendStyles, resolvedBorderColor], ); return ( diff --git a/packages/web/src/system/__tests__/Interactable.test.tsx b/packages/web/src/system/__tests__/Interactable.test.tsx new file mode 100644 index 000000000..808884baf --- /dev/null +++ b/packages/web/src/system/__tests__/Interactable.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; + +import { media } from '../../styles/media'; +import { defaultTheme } from '../../themes/defaultTheme'; +import { Interactable } from '../Interactable'; +import { MediaQueryContext } from '../MediaQueryProvider'; +import { ThemeProvider } from '../ThemeProvider'; + +const responsiveBorderColor = { + base: 'bgLine', + phone: 'bgNegative', + tablet: 'bgPositive', + desktop: 'bgPrimary', +} as const; + +const renderWithWidth = (width?: number) => { + const mediaContextValue = width + ? { + subscribe: () => () => undefined, + getServerSnapshot: () => false, + getSnapshot: (query: string) => { + if (query === media.phone) return width <= 767; + if (query === media.tablet) return width >= 768 && width <= 1279; + if (query === media.desktop) return width >= 1280; + return false; + }, + } + : null; + + const content = ( + + + + ); + + return render( + mediaContextValue ? ( + {content} + ) : ( + content + ), + ); +}; + +describe('Interactable', () => { + it.each([ + ['phone', 500, 'bgNegative'], + ['tablet', 900, 'bgPositive'], + ['desktop', 1400, 'bgPrimary'], + ])('resolves %s borderColor from responsive prop', (_, width, expectedToken) => { + renderWithWidth(width); + const element = screen.getByTestId('interactable'); + expect(element.style.getPropertyValue('--interactable-border-color')).toBe( + `var(--color-${expectedToken})`, + ); + }); + + it('falls back to base value without MediaQueryProvider', () => { + renderWithWidth(); + + const element = screen.getByTestId('interactable'); + expect(element.style.getPropertyValue('--interactable-border-color')).toBe( + `var(--color-${responsiveBorderColor.base})`, + ); + }); +}); From 9519193d052a281d5c58e51d0873093a9057a7b5 Mon Sep 17 00:00:00 2001 From: Boba Date: Wed, 28 Jan 2026 17:10:54 -0800 Subject: [PATCH 2/4] fix: lint issues --- packages/web/src/cells/Cell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/cells/Cell.tsx b/packages/web/src/cells/Cell.tsx index 7c05823db..dcc0180c9 100644 --- a/packages/web/src/cells/Cell.tsx +++ b/packages/web/src/cells/Cell.tsx @@ -499,7 +499,7 @@ export const Cell: CellComponent = memo( accessibilityHint, accessibilityLabel, accessibilityLabelledBy, - borderRadius, + borderProps, classNames?.pressable, disabled, innerSpacingMarginX, From c0756b089dd3f9b463a9b139386010f5daad4c38 Mon Sep 17 00:00:00 2001 From: Boba Date: Tue, 10 Feb 2026 16:21:23 -0800 Subject: [PATCH 3/4] chore: address code review feedback --- packages/mobile/src/cells/Cell.tsx | 3 + .../cells/__stories__/ListCell.stories.tsx | 69 --------------- packages/web/src/cells/Cell.tsx | 3 + .../cells/__stories__/ListCell.stories.tsx | 86 ------------------- packages/web/src/system/Interactable.tsx | 21 +---- packages/web/src/utils/responsive.ts | 47 ++++++++++ 6 files changed, 54 insertions(+), 175 deletions(-) create mode 100644 packages/web/src/utils/responsive.ts diff --git a/packages/mobile/src/cells/Cell.tsx b/packages/mobile/src/cells/Cell.tsx index 1158a4a8b..45108b3a8 100644 --- a/packages/mobile/src/cells/Cell.tsx +++ b/packages/mobile/src/cells/Cell.tsx @@ -160,6 +160,9 @@ export const Cell = memo(function Cell({ const { marginX: innerSpacingMarginX, ...innerSpacingWithoutMarginX } = innerSpacing; + // Border props must be applied to the internal Pressable wrapper for correct visual rendering. + // The outer Box was only meant to create padding outside the Pressable area; this behavior + // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell. const borderProps = useMemo( () => ({ bordered, diff --git a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx index 8d6b44754..180206cad 100644 --- a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx @@ -818,36 +818,12 @@ const BorderCustomization = () => { spacingVariant={spacingVariant} title="borderedTop" /> - - - - { spacingVariant={spacingVariant} title="borderTopWidth" /> - - - { spacingVariant={spacingVariant} title="borderTopLeftRadius" /> - - - ); }; diff --git a/packages/web/src/cells/Cell.tsx b/packages/web/src/cells/Cell.tsx index dcc0180c9..c2761751d 100644 --- a/packages/web/src/cells/Cell.tsx +++ b/packages/web/src/cells/Cell.tsx @@ -272,6 +272,9 @@ export const Cell: CellComponent = memo( const isButton = Boolean(onClick ?? onKeyDown ?? onKeyUp); const linkable = isAnchor || isButton; const contentTruncationStyle = cx(baseCss, shouldTruncate && truncationCss); + // Border props must be applied to the internal Pressable wrapper for correct visual rendering. + // The outer Box was only meant to create padding outside the Pressable area; this behavior + // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell. const borderProps = useMemo( () => ({ bordered, diff --git a/packages/web/src/cells/__stories__/ListCell.stories.tsx b/packages/web/src/cells/__stories__/ListCell.stories.tsx index c10b6f820..79b138506 100644 --- a/packages/web/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/web/src/cells/__stories__/ListCell.stories.tsx @@ -911,30 +911,6 @@ const BorderCustomization = () => { spacingVariant={spacingVariant} title="borderedTop" /> - - - { spacingVariant={spacingVariant} title="borderedHorizontal" /> - { spacingVariant={spacingVariant} title="borderTopWidth" /> - - - { spacingVariant={spacingVariant} title="borderTopLeftRadius" /> - - - ); }; diff --git a/packages/web/src/system/Interactable.tsx b/packages/web/src/system/Interactable.tsx index eb50c5371..fe5d3ef6b 100644 --- a/packages/web/src/system/Interactable.tsx +++ b/packages/web/src/system/Interactable.tsx @@ -14,8 +14,7 @@ import type { Theme } from '../core/theme'; import { cx } from '../cx'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxBaseProps } from '../layout/Box'; -import { media } from '../styles/media'; -import type { ResponsiveProp, ResponsiveValue } from '../styles/styleProps'; +import { resolveResponsiveProp } from '../utils/responsive'; import { interactableBackground, @@ -109,24 +108,6 @@ const transparentWhileInactiveCss = css` } `; -const isResponsiveValue = (value: ResponsiveProp): value is ResponsiveValue => - typeof value === 'object' && - value !== null && - ('base' in value || 'phone' in value || 'tablet' in value || 'desktop' in value); - -const resolveResponsiveProp = ( - value: ResponsiveProp | undefined, - getSnapshot?: (query: string) => boolean, -): T | undefined => { - if (!value || !isResponsiveValue(value)) return value; - const fallback = value.base ?? value.phone ?? value.tablet ?? value.desktop; - if (!getSnapshot) return fallback; - if (typeof value.phone !== 'undefined' && getSnapshot(media.phone)) return value.phone; - if (typeof value.tablet !== 'undefined' && getSnapshot(media.tablet)) return value.tablet; - if (typeof value.desktop !== 'undefined' && getSnapshot(media.desktop)) return value.desktop; - return fallback; -}; - export const interactableDefaultElement = 'button'; export type InteractableDefaultElement = typeof interactableDefaultElement; diff --git a/packages/web/src/utils/responsive.ts b/packages/web/src/utils/responsive.ts new file mode 100644 index 000000000..3fa6f02ae --- /dev/null +++ b/packages/web/src/utils/responsive.ts @@ -0,0 +1,47 @@ +import { media } from '../styles/media'; +import type { ResponsiveProp, ResponsiveValue } from '../styles/styleProps'; +import type { MediaQueryContextValue } from '../system/MediaQueryProvider'; + +/** + * Type for the media query snapshot function. Use this when you need to resolve + * responsive values in JavaScript (e.g., passing to a child component). + * Typically obtained from `MediaQueryContext.getSnapshot`. + */ +export type MediaQueryGetSnapshot = MediaQueryContextValue['getSnapshot']; + +/** + * Type guard to check if a value is a responsive object with breakpoint keys + * (base, phone, tablet, desktop) rather than a scalar value. + */ +export const isResponsiveValue = (value: ResponsiveProp): value is ResponsiveValue => + typeof value === 'object' && + value !== null && + ('base' in value || 'phone' in value || 'tablet' in value || 'desktop' in value); + +/** + * Resolves a ResponsiveProp to a single value based on the current viewport. + * + * Use this when you need the resolved value in JavaScript (e.g., passing to a child + * component or using in conditional logic). For applying responsive styles via CSS, + * use getStyles from styleProps instead—it handles responsive objects via + * media-query CSS variables. + * + * @param value - A scalar value or responsive object with base/phone/tablet/desktop keys + * @param getSnapshot - Function that returns whether a media query matches. Pass + * MediaQueryContext.getSnapshot when used within MediaQueryProvider. Without it, + * returns the first defined value (base ?? phone ?? tablet ?? desktop). + * @returns The resolved value for the current breakpoint, or the fallback when + * getSnapshot is not provided + */ +export const resolveResponsiveProp = ( + value: ResponsiveProp | undefined, + getSnapshot?: MediaQueryGetSnapshot, +): T | undefined => { + if (!value || !isResponsiveValue(value)) return value; + const fallback = value.base ?? value.phone ?? value.tablet ?? value.desktop; + if (!getSnapshot) return fallback; + if (typeof value.phone !== 'undefined' && getSnapshot(media.phone)) return value.phone; + if (typeof value.tablet !== 'undefined' && getSnapshot(media.tablet)) return value.tablet; + if (typeof value.desktop !== 'undefined' && getSnapshot(media.desktop)) return value.desktop; + return fallback; +}; From 0c1c779deff4af40fe8653e322172b7a5afcfa85 Mon Sep 17 00:00:00 2001 From: Boba Date: Wed, 11 Feb 2026 16:09:38 -0800 Subject: [PATCH 4/4] feat: refactored useResolveResponsiveProp --- .../cells/__stories__/ListCell.stories.tsx | 6 +- .../useResolveResponsiveProp.test.tsx | 70 +++++++++++++++++++ .../useResolveResponsiveProp.ts} | 32 ++++----- packages/web/src/system/Interactable.tsx | 11 +-- 4 files changed, 90 insertions(+), 29 deletions(-) create mode 100644 packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx rename packages/web/src/{utils/responsive.ts => hooks/useResolveResponsiveProp.ts} (54%) diff --git a/packages/web/src/cells/__stories__/ListCell.stories.tsx b/packages/web/src/cells/__stories__/ListCell.stories.tsx index 79b138506..9bc9c0900 100644 --- a/packages/web/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/web/src/cells/__stories__/ListCell.stories.tsx @@ -921,7 +921,11 @@ const BorderCustomization = () => { /> ({ + subscribe: () => () => {}, + getServerSnapshot: (query: string) => { + if (query === media.phone) return width <= 767; + if (query === media.tablet) return width >= 768 && width <= 1279; + if (query === media.desktop) return width >= 1280; + return false; + }, + getSnapshot: (query: string) => { + if (query === media.phone) return width <= 767; + if (query === media.tablet) return width >= 768 && width <= 1279; + if (query === media.desktop) return width >= 1280; + return false; + }, +}); + +const responsiveValue = { + base: 'base', + phone: 'phone', + tablet: 'tablet', + desktop: 'desktop', +} as const; + +const wrapper = (width: number) => + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + }; + +describe('useResolveResponsiveProp', () => { + it('returns scalar value unchanged', () => { + const { result } = renderHook(() => useResolveResponsiveProp('scalar'), { + wrapper: wrapper(500), + }); + expect(result.current).toBe('scalar'); + }); + + it('returns undefined for undefined input', () => { + const { result } = renderHook(() => useResolveResponsiveProp(undefined), { + wrapper: wrapper(500), + }); + expect(result.current).toBeUndefined(); + }); + + it.each([ + ['phone', 500, 'phone'], + ['tablet', 900, 'tablet'], + ['desktop', 1400, 'desktop'], + ])('resolves responsive object for %s viewport', (_, width, expected) => { + const { result } = renderHook(() => useResolveResponsiveProp(responsiveValue), { + wrapper: wrapper(width), + }); + expect(result.current).toBe(expected); + }); + + it('falls back to base when outside MediaQueryProvider', () => { + const { result } = renderHook(() => useResolveResponsiveProp(responsiveValue)); + expect(result.current).toBe('base'); + }); +}); diff --git a/packages/web/src/utils/responsive.ts b/packages/web/src/hooks/useResolveResponsiveProp.ts similarity index 54% rename from packages/web/src/utils/responsive.ts rename to packages/web/src/hooks/useResolveResponsiveProp.ts index 3fa6f02ae..e144b3637 100644 --- a/packages/web/src/utils/responsive.ts +++ b/packages/web/src/hooks/useResolveResponsiveProp.ts @@ -1,19 +1,10 @@ +import { useContext } from 'react'; + import { media } from '../styles/media'; import type { ResponsiveProp, ResponsiveValue } from '../styles/styleProps'; -import type { MediaQueryContextValue } from '../system/MediaQueryProvider'; - -/** - * Type for the media query snapshot function. Use this when you need to resolve - * responsive values in JavaScript (e.g., passing to a child component). - * Typically obtained from `MediaQueryContext.getSnapshot`. - */ -export type MediaQueryGetSnapshot = MediaQueryContextValue['getSnapshot']; +import { MediaQueryContext } from '../system/MediaQueryProvider'; -/** - * Type guard to check if a value is a responsive object with breakpoint keys - * (base, phone, tablet, desktop) rather than a scalar value. - */ -export const isResponsiveValue = (value: ResponsiveProp): value is ResponsiveValue => +const isResponsiveValue = (value: ResponsiveProp): value is ResponsiveValue => typeof value === 'object' && value !== null && ('base' in value || 'phone' in value || 'tablet' in value || 'desktop' in value); @@ -26,17 +17,18 @@ export const isResponsiveValue = (value: ResponsiveProp): value is Respons * use getStyles from styleProps instead—it handles responsive objects via * media-query CSS variables. * + * Reads getSnapshot from MediaQueryContext when within MediaQueryProvider. + * Without it, returns the first defined value (base ?? phone ?? tablet ?? desktop). + * * @param value - A scalar value or responsive object with base/phone/tablet/desktop keys - * @param getSnapshot - Function that returns whether a media query matches. Pass - * MediaQueryContext.getSnapshot when used within MediaQueryProvider. Without it, - * returns the first defined value (base ?? phone ?? tablet ?? desktop). - * @returns The resolved value for the current breakpoint, or the fallback when - * getSnapshot is not provided + * @returns The resolved value for the current breakpoint */ -export const resolveResponsiveProp = ( +export const useResolveResponsiveProp = ( value: ResponsiveProp | undefined, - getSnapshot?: MediaQueryGetSnapshot, ): T | undefined => { + const context = useContext(MediaQueryContext); + const getSnapshot = context?.getSnapshot; + if (!value || !isResponsiveValue(value)) return value; const fallback = value.base ?? value.phone ?? value.tablet ?? value.desktop; if (!getSnapshot) return fallback; diff --git a/packages/web/src/system/Interactable.tsx b/packages/web/src/system/Interactable.tsx index fe5d3ef6b..34c227b19 100644 --- a/packages/web/src/system/Interactable.tsx +++ b/packages/web/src/system/Interactable.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useContext, useMemo } from 'react'; +import React, { forwardRef, useMemo } from 'react'; import { getBlendedColor } from '@coinbase/cds-common/color/getBlendedColor'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { @@ -12,9 +12,9 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import type { Theme } from '../core/theme'; import { cx } from '../cx'; +import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxBaseProps } from '../layout/Box'; -import { resolveResponsiveProp } from '../utils/responsive'; import { interactableBackground, @@ -28,7 +28,6 @@ import { interactablePressedBorderColor, interactablePressedOpacity, } from './interactableCSSProperties'; -import { MediaQueryContext } from './MediaQueryProvider'; const COMPONENT_STATIC_CLASSNAME = 'cds-Interactable'; @@ -223,11 +222,7 @@ export const Interactable: InteractableComponent = forwardRef< ) => { const Component = (as ?? interactableDefaultElement) satisfies React.ElementType; const theme = useTheme(); - const mediaQueryContext = useContext(MediaQueryContext); - const resolvedBorderColor = useMemo( - () => resolveResponsiveProp(borderColor, mediaQueryContext?.getSnapshot), - [borderColor, mediaQueryContext?.getSnapshot], - ); + const resolvedBorderColor = useResolveResponsiveProp(borderColor); const interactableStyle = useMemo( () => ({