Skip to content

Commit

Permalink
feat: Implement tooltip component on mobile (#13860)
Browse files Browse the repository at this point in the history
## **Description**

Implementation of the Tooltip component for Snaps UI Renderer.

## **Related issues**

Fixes:
MetaMask/snaps#3177

## **Manual testing steps**

1. Go to the webview for snaps testing
(https://metamask.github.io/snaps/test-snaps/latest/)
2. Scroll down and install JSX Snap. Then press 'Show JSX Snap'
3. Press the Tooltip to see a bottom sheet pop over on the screen with
populated information

## **Screenshots/Recordings**


https://github.com/user-attachments/assets/51b2e503-6cf9-48e7-adf4-0bc6a3495669


## **Pre-merge author checklist**

- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
Daniel-Cross authored Mar 8, 2025
1 parent 74dd739 commit 49d8909
Show file tree
Hide file tree
Showing 7 changed files with 381 additions and 0 deletions.
2 changes: 2 additions & 0 deletions app/components/Snaps/SnapUIRenderer/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { section } from './section';
import { spinner } from './spinner';
import { address } from './address';
import { avatar } from './avatar';
import { tooltip } from './tooltip';

export const COMPONENT_MAPPING = {
Box: box,
Expand All @@ -42,4 +43,5 @@ export const COMPONENT_MAPPING = {
Spinner: spinner,
Avatar: avatar,
Address: address,
Tooltip: tooltip,
};
205 changes: 205 additions & 0 deletions app/components/Snaps/SnapUIRenderer/components/tooltip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { Text, SnapElement, TooltipProps } from '@metamask/snaps-sdk/jsx';
import { tooltip } from './tooltip';
import { mockTheme } from '../../../../util/theme';

describe('tooltip component', () => {
const defaultParams = {
map: {},
useFooter: false,
onCancel: jest.fn(),
t: jest.fn(),
theme: mockTheme,
};

it('should render tooltip with string content', () => {
const e = {
type: 'Tooltip' as const,
props: {
content: 'Tooltip content',
children: [Text({ children: 'Hover me' })],
},
key: null,
};

const result = tooltip({
element: e as unknown as SnapElement<TooltipProps, 'Tooltip'>,
...defaultParams,
});

expect(result).toEqual({
element: 'SnapUITooltip',
children: [
{
element: 'Text',
children: [
{
element: 'RNText',
children: 'Hover me',
props: {
color: 'inherit',
},
key: expect.any(String),
},
],
props: {
color: 'inherit',
fontWeight: 'normal',
textAlign: 'left',
variant: 'sBodyMD',
},
key: expect.any(String),
},
],
propComponents: {
content: {
element: 'Text',
children: [
{
element: 'RNText',
children: 'Tooltip content',
props: {
color: 'inherit',
},
key: expect.any(String),
},
],
props: {
color: 'inherit',
fontWeight: 'normal',
textAlign: 'left',
variant: 'sBodyMD',
},
key: expect.any(String),
},
},
});
});

it('should render tooltip with complex content', () => {
const e = {
type: 'Tooltip' as const,
props: {
content: Text({ children: 'Complex content' }),
children: [Text({ children: 'Hover me' })],
},
key: null,
};

const result = tooltip({
element: e as unknown as SnapElement<TooltipProps, 'Tooltip'>,
...defaultParams,
});

expect(result).toEqual({
element: 'SnapUITooltip',
children: [
{
element: 'Text',
children: [
{
element: 'RNText',
children: 'Hover me',
props: {
color: 'inherit',
},
key: expect.any(String),
},
],
props: {
color: 'inherit',
fontWeight: 'normal',
textAlign: 'left',
variant: 'sBodyMD',
},
key: expect.any(String),
},
],
propComponents: {
content: {
element: 'Text',
children: [
{
element: 'RNText',
children: 'Complex content',
props: {
color: 'inherit',
},
key: expect.any(String),
},
],
props: {
color: 'inherit',
fontWeight: 'normal',
textAlign: 'left',
variant: 'sBodyMD',
},
key: expect.any(String),
},
},
});
});

it('should handle nested children', () => {
const e = {
type: 'Tooltip' as const,
props: {
content: 'Tooltip content',
children: [Text({ children: 'Nested text' })],
},
key: null,
};

const result = tooltip({
element: e as unknown as SnapElement<TooltipProps, 'Tooltip'>,
...defaultParams,
});

expect(result).toEqual({
element: 'SnapUITooltip',
children: [
{
element: 'Text',
children: [
{
element: 'RNText',
children: 'Nested text',
props: {
color: 'inherit',
},
key: expect.any(String),
},
],
props: {
color: 'inherit',
fontWeight: 'normal',
textAlign: 'left',
variant: 'sBodyMD',
},
key: expect.any(String),
},
],
propComponents: {
content: {
element: 'Text',
children: [
{
element: 'RNText',
children: 'Tooltip content',
props: {
color: 'inherit',
},
key: expect.any(String),
},
],
props: {
color: 'inherit',
fontWeight: 'normal',
textAlign: 'left',
variant: 'sBodyMD',
},
key: expect.any(String),
},
},
});
});
});
23 changes: 23 additions & 0 deletions app/components/Snaps/SnapUIRenderer/components/tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { JSXElement, Text, TooltipElement } from '@metamask/snaps-sdk/jsx';
import { getJsxChildren } from '@metamask/snaps-utils';
import { mapToTemplate } from '../utils';
import { UIComponentFactory } from './types';

export const tooltip: UIComponentFactory<TooltipElement> = ({
element: e,
...params
}) => ({
element: 'SnapUITooltip',
children: getJsxChildren(e).map((children) =>
mapToTemplate({ element: children as JSXElement, ...params }),
),
propComponents: {
content: mapToTemplate({
element:
typeof e.props.content === 'string'
? Text({ children: e.props.content })
: e.props.content,
...params,
}),
},
});
28 changes: 28 additions & 0 deletions app/components/Snaps/SnapUITooltip/SnapUITooltip.styles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { StyleSheet } from 'react-native';
import { Theme } from '../../../util/theme/models';
import Device from '../../../util/device';
/**
*
* @param params Style sheet params.
* @param params.theme App theme from ThemeContext.
* @returns StyleSheet object.
*/
const styleSheet = (params: { theme: Theme }) => {
const { theme } = params;
const { colors } = theme;
return StyleSheet.create({
modal: {
backgroundColor: colors.background.default,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
minHeight: '50%',
paddingBottom: Device.isIphoneX() ? 20 : 0,
overflow: 'hidden',
},
content: {
paddingHorizontal: 16,
},
});
};

export default styleSheet;
81 changes: 81 additions & 0 deletions app/components/Snaps/SnapUITooltip/SnapUITooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import { fireEvent, render } from '@testing-library/react-native';
import { SnapUITooltip } from './SnapUITooltip';
import { Text, TouchableOpacity } from 'react-native';
import ApprovalModal from '../../Approvals/ApprovalModal';

jest.mock(
'../../../component-library/components/BottomSheets/BottomSheetHeader',
() => ({
__esModule: true,
default: function BottomSheetHeader({ onBack }: { onBack: () => void }) {
setTimeout(onBack, 0);
return null;
},
}),
);

describe('SnapUITooltip', () => {
it('should render tooltip with content and children', () => {
const content = 'Test content';
const children = 'Click me';
const { getByText } = render(
<SnapUITooltip content={<Text>{content}</Text>}>
<Text>{children}</Text>
</SnapUITooltip>,
);

expect(getByText(children)).toBeTruthy();
});

it('should open modal on press', () => {
const content = 'Test content';
const children = 'Click me';
const { getByText, UNSAFE_getByType } = render(
<SnapUITooltip content={<Text>{content}</Text>}>
<Text>{children}</Text>
</SnapUITooltip>,
);

const touchable = getByText(children).parent as TouchableOpacity;
fireEvent.press(touchable);

const modal = UNSAFE_getByType(ApprovalModal);
expect(modal.props.isVisible).toBe(true);
});

it('should close modal when back action is triggered', async () => {
const content = 'Test content';
const children = 'Click me';
const { getByText, UNSAFE_getByType } = render(
<SnapUITooltip content={<Text>{content}</Text>}>
<Text>{children}</Text>
</SnapUITooltip>,
);

const touchable = getByText(children).parent as TouchableOpacity;
fireEvent.press(touchable);

await new Promise((resolve) => setTimeout(resolve, 0));

const modal = UNSAFE_getByType(ApprovalModal);
expect(modal.props.isVisible).toBe(false);
});

it('should render complex content in modal', () => {
const content = <Text>Complex content</Text>;
const children = 'Click me';
const { getByText, UNSAFE_getByType } = render(
<SnapUITooltip content={content}>
<Text>{children}</Text>
</SnapUITooltip>,
);

const touchable = getByText(children).parent as TouchableOpacity;
fireEvent.press(touchable);

const modal = UNSAFE_getByType(ApprovalModal);
expect(modal).toBeTruthy();
expect(getByText('Complex content')).toBeTruthy();
});
});
40 changes: 40 additions & 0 deletions app/components/Snaps/SnapUITooltip/SnapUITooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { FunctionComponent, ReactNode, useState } from 'react';
import ApprovalModal from '../../Approvals/ApprovalModal';
import { ScrollView, TouchableOpacity, View } from 'react-native';
import { useStyles } from '../../../component-library/hooks/useStyles';
import stylesheet from './SnapUITooltip.styles';
import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader';

export interface SnapUITooltipProps {
content: ReactNode;
children: ReactNode;
}

export const SnapUITooltip: FunctionComponent<SnapUITooltipProps> = ({
content,
children,
}) => {
const { styles } = useStyles(stylesheet, {});

const [isOpen, setIsOpen] = useState(false);

const handleOnOpen = () => {
setIsOpen(true);
};

const handleOnCancel = () => {
setIsOpen(false);
};

return (
<>
<TouchableOpacity onPress={handleOnOpen}>{children}</TouchableOpacity>
<ApprovalModal isVisible={isOpen} onCancel={handleOnCancel}>
<View style={styles.modal}>
<BottomSheetHeader onBack={handleOnCancel} />
<ScrollView style={styles.content}>{content}</ScrollView>
</View>
</ApprovalModal>
</>
);
};
Loading

0 comments on commit 49d8909

Please sign in to comment.