diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 2dce6fb82..196a8e400 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2330,6 +2330,9 @@ "origin": { "message": "Origin" }, + "padlock": { + "message": "Padlock" + }, "parameters": { "message": "Parameters" }, diff --git a/app/images/lock-icon.svg b/app/images/lock-icon.svg new file mode 100644 index 000000000..824974a09 --- /dev/null +++ b/app/images/lock-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/images/unlock-icon.svg b/app/images/unlock-icon.svg new file mode 100644 index 000000000..2ad1eadeb --- /dev/null +++ b/app/images/unlock-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index aac5586b8..62be2129b 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -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'; diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js new file mode 100644 index 000000000..076b02f30 --- /dev/null +++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.js @@ -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 + */ + 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 + */ + const triggerOnLongPressed = (e) => { + onLongPressed(); + setHasTriggeredUnlock(true); + preventPropogation(e); + }; + + /** + * 3. Reset animation states + */ + const resetAnimationStates = () => { + setIsUnlocking(false); + setHasTriggeredUnlock(false); + }; + + const renderPreCompleteContent = useCallback(() => { + return ( + + + + + + + + + + + + + {t('padlock')} + + + ); + }, [isUnlocking, hasTriggeredUnlock, t]); + + const renderPostCompleteContent = useCallback(() => { + return isUnlocking ? ( +
+
+ + + +
+
+ + + +
+
+ {t('padlock')} +
+
+ ) : null; + }, [isUnlocking, hasTriggeredUnlock, t]); + + return ( + + ); +} + +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, +}; diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.stories.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.stories.js new file mode 100644 index 000000000..3f46c1f0d --- /dev/null +++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.stories.js @@ -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 ; +}; + +DefaultStory.storyName = 'Default'; + +DefaultStory.args = { + buttonText: 'Hold to reveal SRP', + onLongPressed: () => console.log('Revealed'), +}; diff --git a/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js new file mode 100644 index 000000000..f3f7c4d97 --- /dev/null +++ b/ui/components/app/hold-to-reveal-button/hold-to-reveal-button.test.js @@ -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(); + + expect(getByText('Hold to reveal SRP')).toBeInTheDocument(); + }); + + it('should render a button when mouse is down and up', () => { + const { getByText } = render(); + + 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(); + + 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(); + + const button = getByText('Hold to reveal SRP'); + + fireEvent.mouseDown(button); + + waitFor(() => { + expect(button.firstChild).toHaveClass( + 'hold-to-reveal-button__unlock-icon-container', + ); + }); + }); +}); diff --git a/ui/components/app/hold-to-reveal-button/index.js b/ui/components/app/hold-to-reveal-button/index.js new file mode 100644 index 000000000..d180f5e69 --- /dev/null +++ b/ui/components/app/hold-to-reveal-button/index.js @@ -0,0 +1 @@ +export { default } from './hold-to-reveal-button'; diff --git a/ui/components/app/hold-to-reveal-button/index.scss b/ui/components/app/hold-to-reveal-button/index.scss new file mode 100644 index 000000000..f9765ec60 --- /dev/null +++ b/ui/components/app/hold-to-reveal-button/index.scss @@ -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; + } +}