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