1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 03:12:42 +02:00

HoldToRevealButton component (#13785)

* Created 'HoldToRevealButton' component

* Added new line within the .svg files

* Lint fix

* CSS fix according to BEM

* Modified unit test
This commit is contained in:
filipsekulic 2022-04-18 14:02:16 +02:00 committed by GitHub
parent 40095cce67
commit 52b043c4f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 461 additions and 0 deletions

View File

@ -2330,6 +2330,9 @@
"origin": {
"message": "Origin"
},
"padlock": {
"message": "Padlock"
},
"parameters": {
"message": "Parameters"
},

3
app/images/lock-icon.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="8" height="9" viewBox="0 0 8 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.03125 3.9375H6.60938V2.67188C6.60938 1.19883 5.41055 0 3.9375 0C2.46445 0 1.26562 1.19883 1.26562 2.67188V3.9375H0.84375C0.37793 3.9375 0 4.31543 0 4.78125V8.15625C0 8.62207 0.37793 9 0.84375 9H7.03125C7.49707 9 7.875 8.62207 7.875 8.15625V4.78125C7.875 4.31543 7.49707 3.9375 7.03125 3.9375ZM5.20312 3.9375H2.67188V2.67188C2.67188 1.97402 3.23965 1.40625 3.9375 1.40625C4.63535 1.40625 5.20312 1.97402 5.20312 2.67188V3.9375Z" fill="#EAF6FF"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -0,0 +1,3 @@
<svg width="7" height="9" viewBox="0 0 7 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.25 4.50001H2.375V2.68771C2.375 1.99162 2.87031 1.4133 3.48906 1.40626C4.11406 1.39923 4.625 1.97052 4.625 2.67189V2.95314C4.625 3.18693 4.79219 3.37501 5 3.37501H5.5C5.70781 3.37501 5.875 3.18693 5.875 2.95314V2.67189C5.875 1.19533 4.80469 -0.00525612 3.49219 1.7306e-05C2.17969 0.00529073 1.125 1.22169 1.125 2.69825V4.50001H0.75C0.335938 4.50001 0 4.87794 0 5.34376V8.15625C0 8.62207 0.335938 9 0.75 9H6.25C6.66406 9 7 8.62207 7 8.15625V5.34376C7 4.87794 6.66406 4.50001 6.25 4.50001Z" fill="#EAF6FF"/>
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@ -40,6 +40,7 @@
@import 'gas-details-item/index';
@import 'gas-details-item/gas-details-item-title/index';
@import 'gas-timing/index';
@import 'hold-to-reveal-button/index';
@import 'home-notification/index';
@import 'info-box/index';
@import 'menu-bar/index';

View File

@ -0,0 +1,192 @@
import React, { useCallback, useContext, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import Button from '../../ui/button';
import { I18nContext } from '../../../contexts/i18n';
import Box from '../../ui/box/box';
import {
ALIGN_ITEMS,
DISPLAY,
JUSTIFY_CONTENT,
} from '../../../helpers/constants/design-system';
const radius = 14;
const strokeWidth = 2;
const radiusWithStroke = radius - strokeWidth / 2;
export default function HoldToRevealButton({ buttonText, onLongPressed }) {
const t = useContext(I18nContext);
const isLongPressing = useRef(false);
const [isUnlocking, setIsUnlocking] = useState(false);
const [hasTriggeredUnlock, setHasTriggeredUnlock] = useState(false);
/**
* Prevent animation events from propogating up
*
* @param e - Native animation event - React.AnimationEvent<HTMLDivElement>
*/
const preventPropogation = (e) => {
e.stopPropagation();
};
/**
* Event for mouse click down
*/
const onMouseDown = () => {
isLongPressing.current = true;
};
/**
* Event for mouse click up
*/
const onMouseUp = () => {
isLongPressing.current = false;
};
/**
* 1. Progress cirle completed. Begin next animation phase (Shrink halo and show unlocked padlock)
*/
const onProgressComplete = () => {
isLongPressing.current && setIsUnlocking(true);
};
/**
* 2. Trigger onLongPressed callback. Begin next animation phase (Shrink unlocked padlock and fade in original content)
*
* @param e - Native animation event - React.AnimationEvent<HTMLDivElement>
*/
const triggerOnLongPressed = (e) => {
onLongPressed();
setHasTriggeredUnlock(true);
preventPropogation(e);
};
/**
* 3. Reset animation states
*/
const resetAnimationStates = () => {
setIsUnlocking(false);
setHasTriggeredUnlock(false);
};
const renderPreCompleteContent = useCallback(() => {
return (
<Box
className={`hold-to-reveal-button__absolute-fill ${
isUnlocking ? 'hold-to-reveal-button__invisible' : null
} ${
hasTriggeredUnlock ? 'hold-to-reveal-button__main-icon-show' : null
}`}
>
<Box className="hold-to-reveal-button__absolute-fill">
<svg className="hold-to-reveal-button__circle-svg">
<circle
className="hold-to-reveal-button__circle-background"
cx={radius}
cy={radius}
r={radiusWithStroke}
/>
</svg>
</Box>
<Box className="hold-to-reveal-button__absolute-fill">
<svg className="hold-to-reveal-button__circle-svg">
<circle
onTransitionEnd={onProgressComplete}
className="hold-to-reveal-button__circle-foreground"
cx={radius}
cy={radius}
r={radiusWithStroke}
/>
</svg>
</Box>
<Box
display={DISPLAY.FLEX}
alignItems={ALIGN_ITEMS.CENTER}
justifyContent={JUSTIFY_CONTENT.CENTER}
className="hold-to-reveal-button__lock-icon-container"
>
<img
src="images/lock-icon.svg"
alt={t('padlock')}
className="hold-to-reveal-button__lock-icon"
/>
</Box>
</Box>
);
}, [isUnlocking, hasTriggeredUnlock, t]);
const renderPostCompleteContent = useCallback(() => {
return isUnlocking ? (
<div
className={`hold-to-reveal-button__absolute-fill ${
hasTriggeredUnlock ? 'hold-to-reveal-button__unlock-icon-hide' : null
}`}
onAnimationEnd={resetAnimationStates}
>
<div
onAnimationEnd={preventPropogation}
className="hold-to-reveal-button__absolute-fill hold-to-reveal-button__circle-static-outer-container"
>
<svg className="hold-to-reveal-button__circle-svg">
<circle
className="hold-to-reveal-button__circle-static-outer"
cx={14}
cy={14}
r={14}
/>
</svg>
</div>
<div
onAnimationEnd={preventPropogation}
className="hold-to-reveal-button__absolute-fill hold-to-reveal-button__circle-static-inner-container"
>
<svg className="hold-to-reveal-button__circle-svg">
<circle
className="hold-to-reveal-button__circle-static-inner"
cx={14}
cy={14}
r={12}
/>
</svg>
</div>
<div
className="hold-to-reveal-button__unlock-icon-container"
onAnimationEnd={triggerOnLongPressed}
>
<img
src="images/unlock-icon.svg"
alt={t('padlock')}
className="hold-to-reveal-button__unlock-icon"
/>
</div>
</div>
) : null;
}, [isUnlocking, hasTriggeredUnlock, t]);
return (
<Button
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
type="primary"
icon={
<Box marginRight={2} className="hold-to-reveal-button__icon-container">
{renderPreCompleteContent()}
{renderPostCompleteContent()}
</Box>
}
className="hold-to-reveal-button__button-hold"
>
{buttonText}
</Button>
);
}
HoldToRevealButton.propTypes = {
/**
* Text to be displayed on the button
*/
buttonText: PropTypes.string.isRequired,
/**
* Function to be called after the animation is finished
*/
onLongPressed: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,22 @@
import React from 'react';
import HoldToRevealButton from './hold-to-reveal-button';
export default {
title: 'Components/App/HoldToRevealButton',
id: __filename,
argTypes: {
buttonText: { control: 'text' },
onLongPressed: { action: 'Revealing the SRP' },
},
};
export const DefaultStory = (args) => {
return <HoldToRevealButton {...args} />;
};
DefaultStory.storyName = 'Default';
DefaultStory.args = {
buttonText: 'Hold to reveal SRP',
onLongPressed: () => console.log('Revealed'),
};

View File

@ -0,0 +1,72 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import HoldToRevealButton from './hold-to-reveal-button';
describe('HoldToRevealButton', () => {
let props = {};
beforeEach(() => {
const mockOnLongPressed = jest.fn();
props = {
onLongPressed: mockOnLongPressed,
buttonText: 'Hold to reveal SRP',
};
});
it('should render a button with label', () => {
const { getByText } = render(<HoldToRevealButton {...props} />);
expect(getByText('Hold to reveal SRP')).toBeInTheDocument();
});
it('should render a button when mouse is down and up', () => {
const { getByText } = render(<HoldToRevealButton {...props} />);
const button = getByText('Hold to reveal SRP');
fireEvent.mouseDown(button);
expect(button).toBeDefined();
fireEvent.mouseUp(button);
expect(button).toBeDefined();
});
it('should not show the locked padlock when a button is long pressed and then should show it after it was lifted off before the animation concludes', () => {
const { getByText } = render(<HoldToRevealButton {...props} />);
const button = getByText('Hold to reveal SRP');
fireEvent.mouseDown(button);
waitFor(() => {
expect(button.firstChild).toHaveClass(
'hold-to-reveal-button__lock-icon-container',
);
});
fireEvent.mouseUp(button);
waitFor(() => {
expect(button.firstChild).not.toHaveClass(
'hold-to-reveal-button__lock-icon-container',
);
});
});
it('should show the unlocked padlock when a button is long pressed for the duration of the animation', () => {
const { getByText } = render(<HoldToRevealButton {...props} />);
const button = getByText('Hold to reveal SRP');
fireEvent.mouseDown(button);
waitFor(() => {
expect(button.firstChild).toHaveClass(
'hold-to-reveal-button__unlock-icon-container',
);
});
});
});

View File

@ -0,0 +1 @@
export { default } from './hold-to-reveal-button';

View File

@ -0,0 +1,164 @@
// Variables
$circle-radius: 14px;
$circle-diameter: $circle-radius * 2;
// Circumference ~ (2*PI*R). We reduced the number a little to create a snappier interaction
$circle-circumference: 82;
$circle-stroke-width: 2px;
// Keyframes
@keyframes collapse {
from {
transform: scale(1);
}
to {
transform: scale(0);
}
}
@keyframes expand {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hold-to-reveal-button {
// Shared styles
&__absolute-fill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
&__icon {
height: $circle-diameter;
width: $circle-diameter;
}
&__circle-shared {
fill: transparent;
stroke-width: $circle-stroke-width;
}
// Class styles
&__button-hold {
padding: 6px 13px 6px 9px !important;
transform: scale(1) !important;
transition: 0.5s transform !important;
&:active {
background-color: var(--primary-1) !important;
transform: scale(1.05) !important;
.hold-to-reveal-button__circle-foreground {
stroke-dashoffset: 0 !important;
}
.hold-to-reveal-button__lock-icon-container {
opacity: 0 !important;
}
}
}
&__absolute-fill {
@extend .hold-to-reveal-button__absolute-fill;
}
&__icon-container {
@extend .hold-to-reveal-button__icon;
position: relative;
}
&__main-icon-show {
animation: 0.4s fadeIn 1.2s forwards;
}
&__invisible {
opacity: 0;
}
&__circle-svg {
@extend .hold-to-reveal-button__icon;
transform: rotate(-90deg);
}
&__circle-background {
@extend .hold-to-reveal-button__circle-shared;
stroke: var(--primary-3);
}
&__circle-foreground {
@extend .hold-to-reveal-button__circle-shared;
stroke: var(--ui-white);
stroke-dasharray: $circle-circumference;
stroke-dashoffset: $circle-circumference;
transition: 1s stroke-dashoffset;
}
&__lock-icon-container {
@extend .hold-to-reveal-button__absolute-fill;
transition: 0.3s opacity;
opacity: 1;
}
&__lock-icon {
width: 7.88px;
height: 9px;
}
&__unlock-icon-hide {
animation: 0.3s collapse 1s forwards;
}
&__circle-static-outer-container {
animation: 0.25s collapse forwards;
}
&__circle-static-outer {
fill: var(--ui-white);
}
&__circle-static-inner-container {
animation: 0.125s collapse forwards;
}
&__circle-static-inner {
fill: var(--primary-1);
}
&__unlock-icon-container {
@extend .hold-to-reveal-button__absolute-fill;
display: flex;
align-items: center;
justify-content: center;
transform: scale(0);
animation: 0.175s expand 0.2s forwards;
}
&__unlock-icon {
width: 14px;
height: 11px;
}
}