HoldToRevealButton component (#13785)
* Created 'HoldToRevealButton' component * Added new line within the .svg files * Lint fix * CSS fix according to BEM * Modified unit testfeature/default_network_editable
parent
40095cce67
commit
52b043c4f2
After Width: | Height: | Size: 556 B |
After Width: | Height: | Size: 616 B |
@ -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, |
||||
}; |
@ -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'), |
||||
}; |
@ -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', |
||||
); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1 @@ |
||||
export { default } from './hold-to-reveal-button'; |
@ -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; |
||||
} |
||||
} |
Loading…
Reference in new issue