Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/mobile/src/buttons/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Icon } from '../icons/Icon';
import { HStack } from '../layout/HStack';
import { Pressable, type PressableBaseProps } from '../system/Pressable';
import { Text } from '../typography/Text';
import { mergeComponentProps } from '../utils/mergeComponentProps';

export const styles = StyleSheet.create({
inline: {
Expand Down Expand Up @@ -89,8 +90,14 @@ export type ButtonBaseProps = SharedProps &
export type ButtonProps = ButtonBaseProps;

export const Button = memo(
forwardRef(function Button(
{
forwardRef(function Button(_props: ButtonProps, ref: React.ForwardedRef<View>) {
const theme = useTheme();
const mergedProps = mergeComponentProps(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 maybe we could consider creating a higher order component to encapsulate and reuse prop merging across all our components

theme?.components?.Button,
_props,
theme?.components?.mergeStyleProps,
);
const {
variant = 'primary',
loading,
transparent,
Expand All @@ -117,10 +124,7 @@ export const Button = memo(
accessibilityLabel,
accessibilityHint,
...props
}: ButtonProps,
ref: React.ForwardedRef<View>,
) {
const theme = useTheme();
} = mergedProps;
const iconSize = compact ? 's' : 'm';
const hasIcon = Boolean(startIcon || endIcon);

Expand Down
45 changes: 26 additions & 19 deletions packages/mobile/src/buttons/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpaci
import { useTheme } from '../hooks/useTheme';
import { Icon } from '../icons/Icon';
import { Pressable, type PressableBaseProps } from '../system/Pressable';
import { mergeComponentProps } from '../utils/mergeComponentProps';

import type { ButtonBaseProps } from './Button';

Expand All @@ -27,26 +28,32 @@ export type IconButtonBaseProps = SharedProps &

export type IconButtonProps = IconButtonBaseProps;

export const IconButton = memo(function IconButton({
name,
active,
variant = 'secondary',
transparent,
compact = true,
background,
color,
borderColor,
borderWidth = 100,
borderRadius = 1000,
feedback = compact ? 'light' : 'normal',
flush,
loading,
style,
accessibilityHint,
accessibilityLabel,
...props
}: IconButtonProps) {
export const IconButton = memo(function IconButton(_props: IconButtonProps) {
const theme = useTheme();
const mergedProps = mergeComponentProps(
theme?.components?.IconButton,
_props,
theme?.components?.mergeStyleProps,
);
const {
name,
active,
variant = 'secondary',
transparent,
compact = true,
background,
color,
borderColor,
borderWidth = 100,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default values like this should probably be encapsulated in our "default" component-level theme right? I know we're not going for 100% coverage when we launch this feature, but this is the type of thing it is being introduce for, right?

borderRadius = 1000,
feedback = compact ? 'light' : 'normal',
flush,
loading,
style,
accessibilityHint,
accessibilityLabel,
...props
} = mergedProps;
const iconSize = compact ? 's' : 'm';

const variantMap = transparent ? transparentVariants : variants;
Expand Down
29 changes: 29 additions & 0 deletions packages/mobile/src/core/theme.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import type { TextStyle, ViewStyle } from 'react-native';
import type { ColorScheme, ThemeVars } from '@coinbase/cds-common/core/theme';

import type { ButtonBaseProps } from '../buttons/Button';
import type { IconButtonBaseProps } from '../buttons/IconButton';

type Shadow = {
shadowColor?: ViewStyle['shadowColor'];
shadowOpacity?: ViewStyle['shadowOpacity'];
shadowOffset?: ViewStyle['shadowOffset'];
shadowRadius?: ViewStyle['shadowRadius'];
};

export type ComponentTheme = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alternative to consider:

Button: Pick<StyleProps, "borderRadius" | "padding" | "paddingHorizontal" | etc.> & {
  className?: string;
   style?: CSSProperties;
}

// this limits customers to only overriding style opinions, keeping the theme oriented around the theme

const customTheme = {
  // ...
  Button: {
    borderRadius: 900 // can only use theme tokens
  }
}

// prevents abuse from using theme to control behavior
  // ...
  Button: {
    onClick: () = {}   // shouldn't support this
    variant: "negative" // doesn't make sense to set this globally
  }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ButtonBaseProps <> StyleProps

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • stronger semantics
  • easier to add more harder to take away

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"variants" can't be themed e.g. Button "negative" can't be configured

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are some "variant" props an anti-pattern? Do they leak coinbase design patterns to open source i.e we are potentially over opinionated than we need to be and customers can achieve patterns/variants by settings props

Button: Partial<ButtonBaseProps>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we will want to be more strict than ever around what goes into base props vs full props. Maybe time for us to holistically examine that pattern together

IconButton: Partial<IconButtonBaseProps>;
/**
* Controls how component props from theme config are merged with local component props.
* @default false
*
* - When `false` (default): Local props simply override theme config props (standard object spread behavior).
* - When `true`: Special merging behavior for styling props:
* - `style`: Shallow merge (local props override theme config)
* - `styles`: Object keys merged, each value shallow merged
* - All other props: Local props override theme config
*/
mergeStyleProps?: boolean;
Comment thread
stacysun-cb marked this conversation as resolved.
};
export type ComponentsConfig<Components = ComponentTheme> = {
[Key in keyof Components]?: Components[Key];
};

export type ThemeConfig = {
/** A unique identifier for the theme. */
id?: string;
Expand Down Expand Up @@ -48,6 +70,13 @@ export type ThemeConfig = {
};

export type Theme = ThemeConfig & {
/**
* Optional component configs at theme level.
* Allows configuring default props for specific components throughout the theme.
* These are merged with props passed directly to components, with local props taking precedence.
* Supports nested ThemeProvider inheritance with shallow merge.
*/
components?: ComponentsConfig;
/** The currently active color scheme for the parent ThemeProvider, either "light" or "dark". */
activeColorScheme: ColorScheme;
/** The light or dark spectrum color values, as appropriate based on the activeColorScheme. */
Expand Down
29 changes: 25 additions & 4 deletions packages/mobile/src/system/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { ColorScheme } from '@coinbase/cds-common/core/theme';

import type { Theme, ThemeConfig } from '../core/theme';
import type { ComponentsConfig, Theme, ThemeConfig } from '../core/theme';

export type ThemeContextValue = Theme;

Expand All @@ -15,9 +15,21 @@ export type ThemeProviderProps = {
theme: ThemeConfig;
activeColorScheme: ColorScheme;
children?: React.ReactNode;
components?: ComponentsConfig;
};

export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProviderProps) => {
export const ThemeProvider = ({
theme,
activeColorScheme,
children,
components,
}: ThemeProviderProps) => {
const parentTheme = useContext(ThemeContext);
const resolvedComponents = useMemo(
() => ({ ...parentTheme?.components, ...components }),
[parentTheme?.components, components],
);

const themeApi = useMemo(() => {
const activeSpectrumKey = activeColorScheme === 'dark' ? 'darkSpectrum' : 'lightSpectrum';
const activeColorKey = activeColorScheme === 'dark' ? 'darkColor' : 'lightColor';
Expand Down Expand Up @@ -52,7 +64,15 @@ export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProvi
};
}, [theme, activeColorScheme]);

return <ThemeContext.Provider value={themeApi}>{children}</ThemeContext.Provider>;
const themeContextValue = useMemo(
() => ({
...themeApi,
components: resolvedComponents,
}),
[themeApi, resolvedComponents],
);

return <ThemeContext.Provider value={themeContextValue}>{children}</ThemeContext.Provider>;
};

export type InvertedThemeProviderProps = {
Expand All @@ -63,12 +83,13 @@ export type InvertedThemeProviderProps = {
export const InvertedThemeProvider = ({ children }: InvertedThemeProviderProps) => {
const context = useContext(ThemeContext);
if (!context) throw Error('InvertedThemeProvider must be used within a ThemeProvider');
const { components, ...theme } = context;
const inverseColorScheme = context.activeColorScheme === 'dark' ? 'light' : 'dark';
const inverseColorKey = context.activeColorScheme === 'dark' ? 'lightColor' : 'darkColor';
const newColorScheme = context[inverseColorKey] ? inverseColorScheme : context.activeColorScheme;

return (
<ThemeProvider activeColorScheme={newColorScheme} theme={context}>
<ThemeProvider activeColorScheme={newColorScheme} components={components} theme={theme}>
{children}
</ThemeProvider>
);
Expand Down
Loading
Loading