diff --git a/packages/mobile/src/cells/Cell.tsx b/packages/mobile/src/cells/Cell.tsx index acf7ab11d..45108b3a8 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,55 @@ 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, + 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 +296,7 @@ export const Cell = memo(function Cell({ ); }, [ - borderRadius, + borderProps, testID, disabled, selected, @@ -281,7 +344,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 +367,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..180206cad 100644 --- a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx @@ -794,6 +794,77 @@ 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..c2761751d 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,56 @@ 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, + 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 +423,7 @@ export const Cell: CellComponent = memo( ); }, [ - borderRadius, + borderProps, contentClassName, classNames?.contentContainer, classNames?.topContent, @@ -410,7 +472,7 @@ export const Cell: CellComponent = memo( accessibilityLabel, accessibilityLabelledBy, background: 'bg' as const, - borderRadius, + ...borderProps, className: cx(pressCss, insetFocusRingCss, classNames?.pressable), disabled, marginX: innerSpacingMarginX, @@ -440,7 +502,7 @@ export const Cell: CellComponent = memo( accessibilityHint, accessibilityLabel, accessibilityLabelledBy, - borderRadius, + borderProps, classNames?.pressable, disabled, innerSpacingMarginX, diff --git a/packages/web/src/cells/__stories__/ListCell.stories.tsx b/packages/web/src/cells/__stories__/ListCell.stories.tsx index 67d664f5a..9bc9c0900 100644 --- a/packages/web/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/web/src/cells/__stories__/ListCell.stories.tsx @@ -883,6 +883,94 @@ 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 +1321,7 @@ const UseCaseShowcase = () => { }; export { + BorderCustomization, CompactContentDeprecated, CompactPressableContentDeprecated, CondensedListCell, diff --git a/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx b/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx new file mode 100644 index 000000000..d289c66f3 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; + +import { media } from '../../styles/media'; +import { MediaQueryContext } from '../../system/MediaQueryProvider'; +import { useResolveResponsiveProp } from '../useResolveResponsiveProp'; + +const createMediaContext = (width: number) => ({ + 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/hooks/useResolveResponsiveProp.ts b/packages/web/src/hooks/useResolveResponsiveProp.ts new file mode 100644 index 000000000..e144b3637 --- /dev/null +++ b/packages/web/src/hooks/useResolveResponsiveProp.ts @@ -0,0 +1,39 @@ +import { useContext } from 'react'; + +import { media } from '../styles/media'; +import type { ResponsiveProp, ResponsiveValue } from '../styles/styleProps'; +import { MediaQueryContext } from '../system/MediaQueryProvider'; + +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. + * + * 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 + * @returns The resolved value for the current breakpoint + */ +export const useResolveResponsiveProp = ( + value: ResponsiveProp | undefined, +): 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; + 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; +}; 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..34c227b19 100644 --- a/packages/web/src/system/Interactable.tsx +++ b/packages/web/src/system/Interactable.tsx @@ -12,6 +12,7 @@ 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'; @@ -163,8 +164,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 +222,7 @@ export const Interactable: InteractableComponent = forwardRef< ) => { const Component = (as ?? interactableDefaultElement) satisfies React.ElementType; const theme = useTheme(); + const resolvedBorderColor = useResolveResponsiveProp(borderColor); const interactableStyle = useMemo( () => ({ @@ -230,11 +230,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})`, + ); + }); +});