diff --git a/app/components/Snaps/SnapUIButton/SnapUIButton.test.tsx b/app/components/Snaps/SnapUIButton/SnapUIButton.test.tsx
new file mode 100644
index 000000000000..e9c23f24873f
--- /dev/null
+++ b/app/components/Snaps/SnapUIButton/SnapUIButton.test.tsx
@@ -0,0 +1,202 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import { SnapUIButton } from './SnapUIButton';
+import { useSnapInterfaceContext } from '../SnapInterfaceContext';
+import { ButtonType, UserInputEventType } from '@metamask/snaps-sdk';
+import Text, {
+ TextColor,
+} from '../../../component-library/components/Texts/Text';
+import { View } from 'react-native';
+import AnimatedLottieView from 'lottie-react-native';
+
+jest.mock('../SnapInterfaceContext', () => ({
+ useSnapInterfaceContext: jest.fn(),
+}));
+
+jest.mock('../../../util/theme', () => ({
+ useTheme: jest.fn().mockReturnValue({
+ colors: {
+ background: { default: '#FFFFFF' },
+ border: { muted: '#CCCCCC', default: '#DDDDDD' },
+ text: { default: '#000000' },
+ primary: { default: '#0376C9' },
+ error: { default: '#D73A49' },
+ },
+ }),
+}));
+
+describe('SnapUIButton', () => {
+ const mockHandleEvent = jest.fn();
+ const mockOnPress = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useSnapInterfaceContext as jest.Mock).mockReturnValue({
+ handleEvent: mockHandleEvent,
+ });
+ });
+
+ const getColorForTextVariant = (color: TextColor): string => {
+ switch (color) {
+ case TextColor.Info:
+ return '#0376C9';
+ case TextColor.Error:
+ return '#D73A49';
+ case TextColor.Muted:
+ return '#000000';
+ default:
+ return '#000000';
+ }
+ };
+
+ const createStyledText = (
+ text: string,
+ color: TextColor = TextColor.Info,
+ ) => (
+
+ {text}
+
+ );
+
+ it('renders correctly with text children', () => {
+ const { getByText } = render(
+ {createStyledText('Test Button')},
+ );
+
+ expect(getByText('Test Button')).toBeTruthy();
+ });
+
+ it('renders correctly when disabled', () => {
+ const { getByText, getByTestId } = render(
+
+ {createStyledText('Disabled Button', TextColor.Muted)}
+ ,
+ );
+
+ expect(getByText('Disabled Button')).toBeTruthy();
+ expect(getByTestId('disabled-button').props.disabled).toBe(true);
+ });
+
+ it('renders with loading state', () => {
+ const { UNSAFE_getByType } = render(
+ {createStyledText('Loading Button')},
+ );
+
+ expect(UNSAFE_getByType(AnimatedLottieView)).toBeTruthy();
+ });
+
+ it('calls onPress and handles ButtonClickEvent when pressed', () => {
+ const { getByText } = render(
+
+ {createStyledText('Click Me')}
+ ,
+ );
+
+ fireEvent.press(getByText('Click Me'));
+
+ expect(mockOnPress).toHaveBeenCalledTimes(1);
+ expect(mockHandleEvent).toHaveBeenCalledWith({
+ event: UserInputEventType.ButtonClickEvent,
+ name: 'test-button',
+ });
+ });
+
+ it('handles FormSubmitEvent when button type is Submit', () => {
+ const { getByText } = render(
+
+ {createStyledText('Submit Form')}
+ ,
+ );
+
+ fireEvent.press(getByText('Submit Form'));
+
+ expect(mockHandleEvent).toHaveBeenCalledWith({
+ event: UserInputEventType.ButtonClickEvent,
+ name: 'test-button',
+ });
+ expect(mockHandleEvent).toHaveBeenCalledWith({
+ event: UserInputEventType.FormSubmitEvent,
+ name: 'test-form',
+ });
+ });
+
+ it('renders icon component correctly', () => {
+ const MockIcon = () => ;
+ MockIcon.displayName = 'Icon';
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('icon-button')).toBeTruthy();
+ expect(getByTestId('mock-icon')).toBeTruthy();
+ });
+
+ it('renders destructive variant button correctly', () => {
+ const { getByTestId } = render(
+
+ {createStyledText('Destructive Button', TextColor.Error)}
+ ,
+ );
+
+ expect(getByTestId('destructive-button')).toBeTruthy();
+ });
+
+ it('renders with custom style', () => {
+ const customStyle = { backgroundColor: '#FF0000', borderRadius: 16 };
+ const { getByTestId } = render(
+
+ {createStyledText('Styled Button')}
+ ,
+ );
+
+ const button = getByTestId('styled-button');
+ expect(button.props.style.backgroundColor).toBe('#FF0000');
+ expect(button.props.style.borderRadius).toBe(16);
+ });
+
+ it('handles non-string, non-icon children properly', () => {
+ const NonStringComponent = () => ;
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId('custom-children-button')).toBeTruthy();
+ expect(getByTestId('non-string-component')).toBeTruthy();
+ });
+
+ it('sets proper accessibility properties', () => {
+ const { getByTestId } = render(
+
+ {createStyledText('Accessible Button')}
+ ,
+ );
+
+ const button = getByTestId('accessible-button');
+ expect(button.props.accessible).toBe(true);
+ expect(button.props.accessibilityRole).toBe('button');
+ expect(button.props.accessibilityLabel).toBe('button-name');
+ });
+
+ it('provides fallback to name for accessibilityLabel when children is not a string', () => {
+ const NonStringComponent = () => ;
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ const button = getByTestId('accessible-button');
+ expect(button.props.accessibilityLabel).toBe('button-name');
+ });
+});
diff --git a/app/components/Snaps/SnapUIButton/SnapUIButton.tsx b/app/components/Snaps/SnapUIButton/SnapUIButton.tsx
index e4729cbe6232..afd8508a1eb0 100644
--- a/app/components/Snaps/SnapUIButton/SnapUIButton.tsx
+++ b/app/components/Snaps/SnapUIButton/SnapUIButton.tsx
@@ -1,45 +1,46 @@
import React, { FunctionComponent } from 'react';
import { ButtonType, UserInputEventType } from '@metamask/snaps-sdk';
-import ButtonLink from '../../../component-library/components/Buttons/Button/variants/ButtonLink';
-import { ButtonLinkProps } from '../../../component-library/components/Buttons/Button/variants/ButtonLink/ButtonLink.types';
+import {
+ TouchableOpacity,
+ StyleSheet,
+ ViewStyle,
+ StyleProp,
+ TouchableOpacityProps,
+} from 'react-native';
import { useSnapInterfaceContext } from '../SnapInterfaceContext';
-import Text, {
- TextColor,
- TextVariant,
-} from '../../../component-library/components/Texts/Text';
import AnimatedLottieView from 'lottie-react-native';
+import { useTheme } from '../../../util/theme';
-export interface SnapUIButtonProps {
+export interface SnapUIButtonProps extends TouchableOpacityProps {
name?: string;
loading?: boolean;
type?: ButtonType;
form?: string;
- variant: keyof typeof COLORS;
- textVariant?: TextVariant;
+ style?: StyleProp;
+ disabled?: boolean;
+ onPress?: () => void;
+ children?: React.ReactNode;
+ testID?: string;
}
-const COLORS = {
- primary: TextColor.Info,
- destructive: TextColor.Error,
- disabled: TextColor.Muted,
-};
-
-export const SnapUIButton: FunctionComponent<
- SnapUIButtonProps & ButtonLinkProps
-> = ({
+export const SnapUIButton: FunctionComponent = ({
name,
children,
form,
type = ButtonType.Button,
- variant = 'primary',
disabled = false,
loading = false,
- textVariant,
+ style,
+ onPress,
+ testID,
...props
}) => {
const { handleEvent } = useSnapInterfaceContext();
+ const { colors } = useTheme();
const handlePress = () => {
+ onPress?.();
+
handleEvent({
event: UserInputEventType.ButtonClickEvent,
name,
@@ -54,34 +55,54 @@ export const SnapUIButton: FunctionComponent<
}
};
- const overriddenVariant = disabled ? 'disabled' : variant;
+ const styles = StyleSheet.create({
+ button: {
+ backgroundColor: colors.background.default,
+ borderRadius: 8,
+ paddingVertical: 8,
+ flexDirection: 'row',
+ alignItems: 'center',
+ ...(style as ViewStyle),
+ },
+ loadingAnimation: {
+ width: 24,
+ height: 24,
+ },
+ });
- const color = COLORS[overriddenVariant as keyof typeof COLORS];
+ if (loading) {
+ return (
+
+
+
+ );
+ }
return (
-
- ) : (
-
- {children}
-
- )
- }
- />
+ accessible
+ accessibilityRole="button"
+ accessibilityLabel={typeof children === 'string' ? children : name}
+ testID={testID}
+ {...props}
+ >
+ {children}
+
);
};
diff --git a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap
index b273123faef1..436f863441ad 100644
--- a/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap
+++ b/app/components/Snaps/SnapUIRenderer/__snapshots__/SnapUIRenderer.test.tsx.snap
@@ -1182,7 +1182,12 @@ exports[`SnapUIRenderer renders complex nested components 1`] = `
}
>
Foo
@@ -1411,7 +1416,12 @@ exports[`SnapUIRenderer renders footers 1`] = `
}
>
Foo
@@ -1629,48 +1639,36 @@ exports[`SnapUIRenderer supports fields with multiple components 1`] = `
}
testID="textfield-endacccessory"
>
-
-
- Submit
-
+ Submit
-
+
@@ -2671,47 +2669,35 @@ exports[`SnapUIRenderer supports forms with fields 1`] = `
-
-
- Submit
-
+ Submit
-
+
@@ -3058,7 +3044,12 @@ exports[`SnapUIRenderer supports the onCancel prop 1`] = `
}
>
Foo
diff --git a/app/components/Snaps/SnapUIRenderer/components/button.test.ts b/app/components/Snaps/SnapUIRenderer/components/button.test.ts
new file mode 100644
index 000000000000..12c0ab8b0e62
--- /dev/null
+++ b/app/components/Snaps/SnapUIRenderer/components/button.test.ts
@@ -0,0 +1,383 @@
+import { ButtonType } from '@metamask/snaps-sdk';
+import { button } from './button';
+import { mockTheme } from '../../../../util/theme';
+import {
+ TextColor,
+ TextVariant,
+} from '../../../../component-library/components/Texts/Text';
+import { UIComponent } from './types';
+
+interface TestButtonElement {
+ type: string;
+ props: {
+ variant: string;
+ disabled: boolean;
+ loading: boolean;
+ children?: (string | MockUIComponent)[];
+ [key: string]: unknown;
+ };
+ key: string;
+ children: (string | MockUIComponent)[];
+}
+
+interface MockUIComponent {
+ element: string;
+ props?: Record;
+ children?: (string | MockUIComponent)[];
+}
+
+jest.mock('@metamask/snaps-utils', () => ({
+ getJsxChildren: jest.fn((elem) => elem.children || []),
+}));
+
+jest.mock('../utils', () => ({
+ mapTextToTemplate: jest.fn((children) =>
+ children.map((child: string | MockUIComponent) => {
+ if (typeof child === 'string') {
+ return child;
+ }
+ return {
+ element: child.element || 'Text',
+ props: child.props || {},
+ children: child.children || [],
+ };
+ }),
+ ),
+}));
+
+describe('button component factory', () => {
+ const mockT = (key: string) => key;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const createButtonElement = (
+ props: Record = {},
+ children: (string | MockUIComponent)[] = ['Button Text'],
+ ): TestButtonElement => ({
+ type: 'Button',
+ props: {
+ variant: 'primary',
+ disabled: false,
+ loading: false,
+ children,
+ ...props,
+ },
+ key: 'test-key',
+ children,
+ });
+
+ it('creates a basic button with text children', () => {
+ const buttonElement = createButtonElement();
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ expect(result.element).toBe('SnapUIButton');
+ expect(result.props?.variant).toBe('primary');
+ expect(result.props?.disabled).toBe(false);
+ expect(result.props?.loading).toBe(false);
+
+ const children = result.children as UIComponent[];
+ expect(children).toHaveLength(1);
+
+ const textChild = children[0] as UIComponent;
+ expect(textChild.element).toBe('Text');
+ expect(textChild.props?.color).toBe(TextColor.Info);
+
+ const styleObj = textChild.props?.style as Record;
+ expect(styleObj?.color).toBe(mockTheme.colors.primary.default);
+ });
+
+ it('sets button props correctly based on input', () => {
+ const buttonElement = createButtonElement({
+ variant: 'destructive',
+ disabled: true,
+ loading: true,
+ name: 'test-button',
+ type: ButtonType.Submit,
+ form: 'test-form',
+ });
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: 'parent-form',
+ map: {},
+ t: mockT,
+ });
+
+ expect(result.props?.variant).toBe('destructive');
+ expect(result.props?.disabled).toBe(true);
+ expect(result.props?.loading).toBe(true);
+ expect(result.props?.name).toBe('test-button');
+ expect(result.props?.type).toBe(ButtonType.Submit);
+ expect(result.props?.form).toBe('test-form');
+ });
+
+ it('uses parent form when button form is not provided', () => {
+ const buttonElement = createButtonElement();
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: 'parent-form',
+ map: {},
+ t: mockT,
+ });
+
+ expect(result.props?.form).toBe('parent-form');
+ });
+
+ it('handles size property and sets text variant correctly', () => {
+ const smallButtonElement = createButtonElement({ size: 'sm' });
+ const mediumButtonElement = createButtonElement({ size: 'md' });
+
+ const smallResult = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: smallButtonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const mediumResult = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: mediumButtonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ expect(smallResult.props?.textVariant).toBe(TextVariant.BodySMMedium);
+ expect(mediumResult.props?.textVariant).toBe(TextVariant.BodyMDMedium);
+ });
+
+ it('applies disabled styling correctly', () => {
+ const buttonElement = createButtonElement({ disabled: true });
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const textChild = (result.children as UIComponent[])[0] as UIComponent;
+ expect(textChild.props?.color).toBe(TextColor.Muted);
+
+ const styleObj = textChild.props?.style as Record;
+ expect(styleObj?.color).toBe(mockTheme.colors.text.muted);
+ });
+
+ it('applies destructive variant styling correctly', () => {
+ const buttonElement = createButtonElement({ variant: 'destructive' });
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const textChild = (result.children as UIComponent[])[0] as UIComponent;
+ expect(textChild.props?.color).toBe(TextColor.Error);
+
+ const styleObj = textChild.props?.style as Record;
+ expect(styleObj?.color).toBe(mockTheme.colors.error.default);
+ });
+
+ it('styles complex children recursively', () => {
+ const nestedTextComponent: MockUIComponent = {
+ element: 'Text',
+ props: {},
+ children: ['Nested Text'],
+ };
+
+ const complexTextComponent: MockUIComponent = {
+ element: 'Text',
+ props: {},
+ children: ['Parent Text', nestedTextComponent],
+ };
+
+ const buttonElement = createButtonElement({}, [complexTextComponent]);
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const parent = (result.children as UIComponent[])[0] as UIComponent;
+ expect(parent.element).toBe('Text');
+ expect(parent.props?.color).toBe(TextColor.Info);
+
+ const styleObj = parent.props?.style as Record;
+ expect(styleObj?.color).toBe(mockTheme.colors.primary.default);
+ });
+
+ it('styles components with sections correctly', () => {
+ const sectionContainer: MockUIComponent = {
+ element: 'SectionContainer',
+ props: {
+ sections: [
+ {
+ element: 'RNText',
+ props: {},
+ },
+ {
+ element: 'SnapUIIcon',
+ props: {
+ name: 'test-icon',
+ size: 24,
+ },
+ },
+ ],
+ },
+ };
+
+ const buttonElement = createButtonElement({}, [sectionContainer]);
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const container = (result.children as UIComponent[])[0] as UIComponent;
+ expect(container.element).toBe('SectionContainer');
+
+ const sections = container.props?.sections as {
+ element: string;
+ props: Record;
+ }[];
+ expect(sections).toHaveLength(2);
+
+ const textSection = sections[0];
+ expect(textSection.element).toBe('RNText');
+ const textSectionStyle = textSection.props.style as Record;
+ expect(textSectionStyle.color).toBe(mockTheme.colors.primary.default);
+
+ const iconSection = sections[1];
+ expect(iconSection.element).toBe('SnapUIIcon');
+ expect(iconSection.props.color).toBe(mockTheme.colors.primary.default);
+ });
+
+ it('handles icon components correctly', () => {
+ const iconComponent: MockUIComponent = {
+ element: 'SnapUIIcon',
+ props: {
+ name: 'test-icon',
+ size: 24,
+ },
+ };
+
+ const buttonElement = createButtonElement({}, [iconComponent]);
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const icon = (result.children as UIComponent[])[0] as UIComponent;
+ expect(icon.element).toBe('SnapUIIcon');
+ expect(icon.props?.color).toBe(mockTheme.colors.primary.default);
+ });
+
+ it('handles mixed text and icon children', () => {
+ const iconComponent: MockUIComponent = {
+ element: 'SnapUIIcon',
+ props: {
+ name: 'test-icon',
+ size: 24,
+ },
+ };
+
+ const buttonElement = createButtonElement({}, [
+ 'Text Content',
+ iconComponent,
+ ]);
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const children = result.children as UIComponent[];
+ expect(children).toHaveLength(2);
+
+ const textChild = children[0] as UIComponent;
+ expect(textChild.element).toBe('Text');
+ expect(textChild.props?.color).toBe(TextColor.Info);
+
+ const iconChild = children[1] as UIComponent;
+ expect(iconChild.element).toBe('SnapUIIcon');
+ expect(iconChild.props?.color).toBe(mockTheme.colors.primary.default);
+ });
+
+ it('applies the same color to both text and icons', () => {
+ const textComponent: MockUIComponent = {
+ element: 'Text',
+ props: {},
+ children: ['Text'],
+ };
+
+ const iconComponent: MockUIComponent = {
+ element: 'SnapUIIcon',
+ props: {
+ name: 'test-icon',
+ size: 24,
+ },
+ };
+
+ const buttonElement = createButtonElement({}, [
+ textComponent,
+ iconComponent,
+ ]);
+
+ const result = button({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ element: buttonElement as any,
+ theme: mockTheme,
+ form: undefined,
+ map: {},
+ t: mockT,
+ });
+
+ const children = result.children as UIComponent[];
+ expect(children).toHaveLength(2);
+
+ const textChild = children[0] as UIComponent;
+ const iconChild = children[1] as UIComponent;
+
+ const textStyle = textChild.props?.style as Record;
+ expect(textStyle?.color).toBe(mockTheme.colors.primary.default);
+ expect(iconChild.props?.color).toBe(mockTheme.colors.primary.default);
+ });
+});
diff --git a/app/components/Snaps/SnapUIRenderer/components/button.ts b/app/components/Snaps/SnapUIRenderer/components/button.ts
index ce8093e4f226..7f46d61d2adc 100644
--- a/app/components/Snaps/SnapUIRenderer/components/button.ts
+++ b/app/components/Snaps/SnapUIRenderer/components/button.ts
@@ -6,8 +6,13 @@ import {
import { getJsxChildren } from '@metamask/snaps-utils';
import { NonEmptyArray } from '@metamask/utils';
import { mapTextToTemplate } from '../utils';
-import { UIComponentFactory } from './types';
-import { TextVariant } from '../../../../component-library/components/Texts/Text';
+import { UIComponent, UIComponentFactory } from './types';
+import {
+ TextColor,
+ TextVariant,
+} from '../../../../component-library/components/Texts/Text';
+import { Theme } from '@metamask/design-tokens';
+import { StyleProp, TextStyle } from 'react-native';
interface ButtonElementProps extends ButtonElement {
props: ButtonProps & {
@@ -15,27 +20,201 @@ interface ButtonElementProps extends ButtonElement {
size?: 'sm' | 'md';
};
}
+interface CommonProps {
+ style?: StyleProp;
+ color?: TextColor | string;
+ variant?: TextVariant;
+ [key: string]: unknown;
+}
+
+interface TextComponentProps extends CommonProps {
+ color?: TextColor;
+ variant: TextVariant;
+}
+
+interface IconComponentProps extends CommonProps {
+ color?: string;
+ name: string;
+ size: string | number;
+}
+
+// Section component types
+interface SectionComponent {
+ element: string;
+ props: CommonProps;
+}
+
+interface TextSectionComponent extends SectionComponent {
+ element: 'RNText' | 'Text';
+ props: TextComponentProps;
+}
+
+interface IconSectionComponent extends SectionComponent {
+ element: 'SnapUIIcon';
+ props: IconComponentProps;
+}
+
+type ProcessedSectionComponent =
+ | TextSectionComponent
+ | IconSectionComponent
+ | SectionComponent;
+
+interface ComponentWithSections extends UIComponent {
+ props: {
+ sections: ProcessedSectionComponent[];
+ [key: string]: unknown;
+ };
+}
+
+const COLORS = {
+ primary: TextColor.Info,
+ destructive: TextColor.Error,
+ disabled: TextColor.Muted,
+};
+
+/**
+ * Applies styling to button content elements based on the button variant
+ */
+function processButtonContent(
+ content: NonEmptyArray,
+ variant: string,
+ disabled: boolean,
+ textVariant: TextVariant,
+ theme: Theme,
+): NonEmptyArray {
+ const overriddenVariant = disabled ? 'disabled' : variant;
+ const enumColor = COLORS[overriddenVariant as keyof typeof COLORS];
+
+ const themeColor =
+ enumColor === TextColor.Info
+ ? theme.colors.primary.default
+ : enumColor === TextColor.Error
+ ? theme.colors.error.default
+ : theme.colors.text.muted;
+
+ /**
+ * Apply styles to a component based on its type
+ */
+ function styleComponent(component: UIComponent): UIComponent {
+ const result: UIComponent = {
+ ...component,
+ };
+
+ if (component.element === 'Text' || component.element === 'RNText') {
+ result.props = {
+ ...component.props,
+ color: enumColor,
+ style: {
+ ...((component.props?.style as Record) || {}),
+ color: themeColor,
+ },
+ };
+ } else if (component.element === 'SnapUIIcon') {
+ result.props = {
+ ...component.props,
+ color: themeColor,
+ };
+ }
+
+ if (component.children && Array.isArray(component.children)) {
+ result.children = component.children
+ .map((child) => {
+ if (typeof child === 'string') return child;
+ if (!child || typeof child !== 'object') return null;
+ return styleComponent(child as UIComponent);
+ })
+ .filter(Boolean) as NonEmptyArray;
+ }
+
+ return result;
+ }
+
+ return content.map((child) => {
+ if (typeof child === 'string') {
+ return {
+ element: 'Text',
+ props: {
+ color: enumColor,
+ variant: textVariant,
+ style: { color: themeColor },
+ },
+ children: [child],
+ };
+ }
+
+ if (child.props && 'sections' in child.props) {
+ const componentWithSections = child as ComponentWithSections;
+ const modifiedSections = componentWithSections.props.sections.map(
+ (section) => {
+ const result = { ...section };
+
+ if (section.element === 'RNText') {
+ result.props = {
+ ...section.props,
+ style: {
+ ...((section.props.style as Record) || {}),
+ color: themeColor,
+ },
+ };
+ } else if (section.element === 'SnapUIIcon') {
+ result.props = {
+ ...section.props,
+ color: themeColor,
+ };
+ }
+
+ return result;
+ },
+ );
+
+ return {
+ ...componentWithSections,
+ props: {
+ ...componentWithSections.props,
+ sections: modifiedSections,
+ },
+ };
+ }
+
+ return styleComponent(child);
+ }) as NonEmptyArray;
+}
export const button: UIComponentFactory = ({
element: e,
+ theme,
...params
-}) => ({
- element: 'SnapUIButton',
- props: {
- type: e.props.type,
- // This differs from the extension implementation because we don't have proper form support on RN
- form: e.props.form ?? params.form,
- variant: e.props.variant,
- name: e.props.name,
- disabled: e.props.disabled,
- loading: e.props.loading ?? false,
- textVariant:
- e.props.size === 'sm'
- ? TextVariant.BodySMMedium
- : TextVariant.BodyMDMedium,
- },
- children: mapTextToTemplate(
+}) => {
+ const textVariant =
+ e.props.size === 'sm' ? TextVariant.BodySMMedium : TextVariant.BodyMDMedium;
+
+ const mappedChildren = mapTextToTemplate(
getJsxChildren(e) as NonEmptyArray,
- params,
- ),
-});
+ {
+ ...params,
+ theme,
+ },
+ );
+
+ const processedChildren = processButtonContent(
+ mappedChildren,
+ e.props.variant || 'primary',
+ !!e.props.disabled,
+ textVariant,
+ theme,
+ );
+
+ return {
+ element: 'SnapUIButton',
+ props: {
+ type: e.props.type,
+ form: e.props.form ?? params.form,
+ variant: e.props.variant || 'primary',
+ name: e.props.name,
+ disabled: e.props.disabled,
+ loading: e.props.loading ?? false,
+ textVariant,
+ },
+ children: processedChildren,
+ };
+};
diff --git a/app/components/Snaps/SnapUIRenderer/components/footer.test.ts b/app/components/Snaps/SnapUIRenderer/components/footer.test.ts
index 74b3710bddff..c43df2a7f8e9 100644
--- a/app/components/Snaps/SnapUIRenderer/components/footer.test.ts
+++ b/app/components/Snaps/SnapUIRenderer/components/footer.test.ts
@@ -2,6 +2,7 @@ import { ButtonElement, FooterElement } from '@metamask/snaps-sdk/jsx';
import { footer, DEFAULT_FOOTER } from './footer';
import { mockTheme } from '../../../../util/theme';
import { ButtonVariants } from '../../../../component-library/components/Buttons/Button';
+import { UIComponent } from './types';
describe('footer', () => {
const mockT = (value: string) => `translated_${value}`;
@@ -46,34 +47,34 @@ describe('footer', () => {
theme: mockTheme,
});
- expect(result).toEqual({
- ...DEFAULT_FOOTER,
- children: [
- {
- element: 'SnapUIFooterButton',
- key: 'snap-footer-button-0',
- props: {
- disabled: undefined,
- form: undefined,
- isSnapAction: true,
- loading: false,
- name: undefined,
- onCancel: undefined,
- textVariant: 'sBodyMDMedium',
- type: undefined,
- variant: 'Primary',
- },
- children: [
- {
- key: '57fd48ba929aa415dc4c3996c826a75f8686418c77765eb14fad2658efa73d87_1',
- element: 'RNText',
- children: 'Button',
- props: { color: 'inherit' },
- },
- ],
- },
- ],
+ expect(result.element).toBe(DEFAULT_FOOTER.element);
+
+ const children = result.children as UIComponent[];
+ expect(children).toBeTruthy();
+ expect(Array.isArray(children)).toBe(true);
+
+ const buttonComponent = children[0];
+ expect(buttonComponent.element).toBe('SnapUIFooterButton');
+ expect(buttonComponent.props).toMatchObject({
+ disabled: undefined,
+ form: undefined,
+ isSnapAction: true,
+ loading: false,
+ name: undefined,
+ onCancel: undefined,
+ textVariant: 'sBodyMDMedium',
+ type: undefined,
+ variant: 'Primary',
});
+
+ const buttonChildren = buttonComponent.children as UIComponent[];
+ expect(buttonChildren).toBeTruthy();
+ expect(Array.isArray(buttonChildren)).toBe(true);
+
+ const textComponent = buttonChildren[0];
+ expect(textComponent.element).toBe('RNText');
+ expect(textComponent.children).toBe('Button');
+ expect(textComponent.props?.color).toBe('Info');
});
it('add cancel button when onCancel is provided and only one child', () => {
@@ -89,18 +90,42 @@ describe('footer', () => {
theme: mockTheme,
});
- expect(Array.isArray(result.children)).toBe(true);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- expect((result.children as any[])[0]).toEqual({
- element: 'SnapUIFooterButton',
- key: 'default-button',
- props: {
- isSnapAction: false,
- onCancel: mockOnCancel,
- variant: 'Secondary',
- },
- children: 'translated_template_confirmation.cancel',
+ const children = result.children as UIComponent[];
+ expect(children).toBeTruthy();
+ expect(Array.isArray(children)).toBe(true);
+
+ const cancelButton = children[0];
+ expect(cancelButton.element).toBe('SnapUIFooterButton');
+ expect(cancelButton.props).toMatchObject({
+ isSnapAction: false,
+ onCancel: mockOnCancel,
+ variant: 'Secondary',
});
+
+ if (typeof cancelButton.children === 'string') {
+ expect(cancelButton.children).toBe(
+ 'translated_template_confirmation.cancel',
+ );
+ } else {
+ const buttonChildren = cancelButton.children as (string | UIComponent)[];
+
+ if (Array.isArray(buttonChildren) && buttonChildren.length > 0) {
+ const content = buttonChildren[0];
+
+ if (typeof content === 'string') {
+ expect(content).toBe('translated_template_confirmation.cancel');
+ } else if (
+ content &&
+ typeof content === 'object' &&
+ 'children' in content
+ ) {
+ const textContent = content.children;
+ if (typeof textContent === 'string') {
+ expect(textContent).toBe('translated_template_confirmation.cancel');
+ }
+ }
+ }
+ }
});
it('handle multiple buttons with correct variants', () => {
@@ -116,19 +141,52 @@ describe('footer', () => {
theme: mockTheme,
});
- expect(Array.isArray(result.children)).toBe(true);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- expect((result.children as any[])[0].props.variant).toBe(
- ButtonVariants.Secondary,
- );
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- expect((result.children as any[])[1].props.variant).toBe(
- ButtonVariants.Primary,
- );
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- expect((result.children as any[])[0].props.isSnapAction).toBe(true);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- expect((result.children as any[])[1].props.isSnapAction).toBe(true);
+ const children = result.children as UIComponent[];
+ expect(children).toBeTruthy();
+ expect(Array.isArray(children)).toBe(true);
+ expect(children.length).toBeGreaterThanOrEqual(2);
+
+ const rejectButton = children[0];
+ expect(rejectButton.props?.variant).toBe(ButtonVariants.Secondary);
+ expect(rejectButton.props?.isSnapAction).toBe(true);
+
+ const confirmButton = children[1];
+ expect(confirmButton.props?.variant).toBe(ButtonVariants.Primary);
+ expect(confirmButton.props?.isSnapAction).toBe(true);
+
+ const rejectButtonChildren = rejectButton.children as (
+ | string
+ | UIComponent
+ )[];
+ if (
+ Array.isArray(rejectButtonChildren) &&
+ rejectButtonChildren.length > 0
+ ) {
+ const textContent = rejectButtonChildren[0];
+ if (typeof textContent === 'object' && textContent.element === 'RNText') {
+ const text = textContent.children;
+ if (typeof text === 'string') {
+ expect(text).toBe('Reject');
+ }
+ }
+ }
+
+ const confirmButtonChildren = confirmButton.children as (
+ | string
+ | UIComponent
+ )[];
+ if (
+ Array.isArray(confirmButtonChildren) &&
+ confirmButtonChildren.length > 0
+ ) {
+ const textContent = confirmButtonChildren[0];
+ if (typeof textContent === 'object' && textContent.element === 'RNText') {
+ const text = textContent.children;
+ if (typeof text === 'string') {
+ expect(text).toBe('Confirm');
+ }
+ }
+ }
});
it('use index as key when button name is not provided', () => {
@@ -143,7 +201,11 @@ describe('footer', () => {
theme: mockTheme,
});
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- expect((result.children as any[])[0].key).toBe('snap-footer-button-0');
+ const children = result.children as UIComponent[];
+ expect(children).toBeTruthy();
+ expect(Array.isArray(children)).toBe(true);
+
+ const button = children[0];
+ expect(button.key).toBe('snap-footer-button-0');
});
});