Commit 48b4a533 authored by Nate Wienert's avatar Nate Wienert Committed by GitHub

feat: disconnect button has confirmation step with animated width transition...

feat: disconnect button has confirmation step with animated width transition to show confirm text (#6668)

* feat: changes disconnect button to a two-step animated button with confirmation

* fix: use ConfirmSwapModal in expert mode (#6673)

Fixes the swap flow for users who are still in expert mode  and need permit2 approvals for a token

---------
Co-authored-by: default avatareddie <66155195+just-toby@users.noreply.github.com>
parent 0b66fde2
......@@ -45,6 +45,9 @@ describe('Wallet Dropdown', () => {
beforeEach(() => {
cy.visit('/')
cy.get(getTestSelector('web3-status-connected')).click()
// click twice, first time to show confirmation, second to confirm
cy.get(getTestSelector('wallet-disconnect')).click()
cy.get(getTestSelector('wallet-disconnect')).should('contain', 'Disconnect')
cy.get(getTestSelector('wallet-disconnect')).click()
cy.get(getTestSelector('wallet-settings')).click()
})
......
......@@ -17,7 +17,7 @@ import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hoo
import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable'
import { ProfilePageStateType } from 'nft/types'
import { useCallback, useState } from 'react'
import { ArrowDownRight, ArrowUpRight, Copy, CreditCard, IconProps, Info, Power, Settings } from 'react-feather'
import { ArrowDownRight, ArrowUpRight, Copy, CreditCard, IconProps, Info, LogOut, Settings } from 'react-feather'
import { useNavigate } from 'react-router-dom'
import { shouldDisableNFTRoutesAtom } from 'state/application/atoms'
import { useAppDispatch } from 'state/hooks'
......@@ -31,7 +31,7 @@ import { ApplicationModal } from '../../state/application/reducer'
import { useUserHasAvailableClaim, useUserUnclaimedAmount } from '../../state/claim/hooks'
import StatusIcon from '../Identicon/StatusIcon'
import { useToggleAccountDrawer } from '.'
import IconButton, { IconHoverText } from './IconButton'
import IconButton, { IconHoverText, IconWithConfirmTextButton } from './IconButton'
import MiniPortfolio from './MiniPortfolio'
import { portfolioFadeInAnimation } from './MiniPortfolio/PortfolioRow'
......@@ -103,7 +103,9 @@ const FiatOnrampAvailabilityExternalLink = styled(ExternalLink)`
const StatusWrapper = styled.div`
display: inline-block;
width: 70%;
padding-right: 4px;
max-width: 70%;
overflow: hidden;
padding-right: 14px;
display: inline-flex;
`
......@@ -158,6 +160,10 @@ export function PortfolioArrow({ change, ...rest }: { change: number } & IconPro
)
}
const LogOutCentered = styled(LogOut)`
transform: translateX(2px);
`
export default function AuthenticatedHeader({ account, openSettings }: { account: string; openSettings: () => void }) {
const { connector, ENSName } = useWeb3React()
const dispatch = useAppDispatch()
......@@ -232,6 +238,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
const totalBalance = portfolio?.tokensTotalDenominatedValue?.value
const absoluteChange = portfolio?.tokensTotalDenominatedValueChange?.absolute?.value
const percentChange = portfolio?.tokensTotalDenominatedValueChange?.percentage?.value
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false)
return (
<AuthenticatedHeaderWrapper>
......@@ -253,13 +260,21 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
)}
</StatusWrapper>
<IconContainer>
{!showDisconnectConfirm && (
<IconButton data-testid="wallet-settings" onClick={openSettings} Icon={Settings} />
)}
<TraceEvent
events={[BrowserEvent.onClick]}
name={SharedEventName.ELEMENT_CLICKED}
element={InterfaceElementName.DISCONNECT_WALLET_BUTTON}
>
<IconButton data-testid="wallet-disconnect" onClick={disconnect} Icon={Power} />
<IconWithConfirmTextButton
data-testid="wallet-disconnect"
onConfirm={disconnect}
onShowConfirm={setShowDisconnectConfirm}
Icon={LogOutCentered}
text="Disconnect"
/>
</TraceEvent>
</IconContainer>
</HeaderWrapper>
......
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { Icon } from 'react-feather'
import styled, { css } from 'styled-components/macro'
import useResizeObserver from 'use-resize-observer'
import Row from '../Row'
export const IconHoverText = styled.span`
color: ${({ theme }) => theme.textPrimary};
......@@ -13,12 +17,17 @@ export const IconHoverText = styled.span`
left: 10px;
`
const widthTransition = `width ease-in 80ms`
const IconStyles = css`
background-color: ${({ theme }) => theme.backgroundInteractive};
transition: ${widthTransition};
border-radius: 12px;
display: inline-block;
display: flex;
padding: 0;
cursor: pointer;
position: relative;
overflow: hidden;
height: 32px;
width: 32px;
color: ${({ theme }) => theme.textPrimary};
......@@ -28,7 +37,7 @@ const IconStyles = css`
theme: {
transition: { duration, timing },
},
}) => `${duration.fast} background-color ${timing.in}`};
}) => `${duration.fast} background-color ${timing.in}, ${widthTransition}`};
${IconHoverText} {
opacity: 1;
......@@ -36,7 +45,7 @@ const IconStyles = css`
}
:active {
background-color: ${({ theme }) => theme.backgroundSurface};
transition: background-color 50ms linear;
transition: background-color 50ms linear, ${widthTransition};
}
`
......@@ -51,27 +60,29 @@ const IconBlockButton = styled.button`
`
const IconWrapper = styled.span`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
margin: auto;
display: flex;
`
interface BaseProps {
Icon: Icon
children?: React.ReactNode
}
interface IconLinkProps extends React.ComponentPropsWithoutRef<'a'>, BaseProps {}
interface IconButtonProps extends React.ComponentPropsWithoutRef<'button'>, BaseProps {}
const IconBlock = (props: React.ComponentPropsWithoutRef<'a' | 'button'>) => {
type IconBlockProps = React.ComponentPropsWithoutRef<'a' | 'button'>
const IconBlock = forwardRef<HTMLAnchorElement | HTMLDivElement, IconBlockProps>(function IconBlock(props, ref) {
if ('href' in props) {
return <IconBlockLink {...props} />
return <IconBlockLink ref={ref as React.ForwardedRef<HTMLAnchorElement>} {...props} />
}
// ignoring 'button' 'type' conflict between React and styled-components
// @ts-ignore
return <IconBlockButton {...props} />
}
return <IconBlockButton ref={ref} {...props} />
})
const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => (
<IconBlock {...rest}>
......@@ -81,4 +92,119 @@ const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => (
</IconBlock>
)
type IconWithTextProps = (IconButtonProps | IconLinkProps) & {
text: string
onConfirm?: () => void
onShowConfirm?: (on: boolean) => void
}
const TextWrapper = styled.div`
display: flex;
flex-shrink: 0;
overflow: hidden;
min-width: min-content;
`
const TextHide = styled.div`
overflow: hidden;
`
/**
* Allows for hiding and showing some text next to an IconButton
* Note that for width transitions to animate in CSS we need to always specify the width (no auto)
* so there's resize observing and measuring going on here.
*/
export const IconWithConfirmTextButton = ({
Icon,
text,
onConfirm,
onShowConfirm,
onClick,
...rest
}: IconWithTextProps) => {
const [showText, setShowTextWithoutCallback] = useState(false)
const frameObserver = useResizeObserver<HTMLElement>()
const hiddenObserver = useResizeObserver<HTMLElement>()
const setShowText = useCallback(
(val: boolean) => {
setShowTextWithoutCallback(val)
onShowConfirm?.(val)
},
[onShowConfirm]
)
const dimensionsRef = useRef({
frame: 0,
hidden: 0,
})
const dimensions = (() => {
// once opened, we avoid updating it to prevent constant resize loop
if (!showText) {
dimensionsRef.current = { frame: frameObserver.width || 0, hidden: hiddenObserver.width || 0 }
}
return dimensionsRef.current
})()
// keyboard action to cancel
useEffect(() => {
if (!showText) return
const isClient = typeof window !== 'undefined'
if (!isClient) return
if (!showText) return
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowText(false)
e.preventDefault()
e.stopPropagation()
}
}
window.addEventListener('keydown', keyHandler, { capture: true })
return () => {
window.removeEventListener('keydown', keyHandler, { capture: true })
}
}, [setShowText, showText])
const xPad = showText ? 12 : 0
const width = showText ? dimensions.frame + dimensions.hidden + xPad : 32
return (
<IconBlock
ref={frameObserver.ref}
{...rest}
style={{
width,
paddingLeft: xPad,
paddingRight: xPad,
}}
// @ts-ignore MouseEvent is valid, its a subset of the two mouse events,
// even manually typing this all out more specifically it still gets mad about any casting for some reason
onClick={(e: MouseEvent<HTMLAnchorElement>) => {
if (showText) {
onConfirm?.()
} else {
onClick?.(e)
setShowText(!showText)
}
}}
>
<Row height="100%" gap="xs">
<IconWrapper>
<Icon strokeWidth={1.5} size={16} />
</IconWrapper>
{/* this outer div is so we can cut it off but keep the inner text width full-width so we can measure it */}
<TextHide
style={{
maxWidth: showText ? dimensions.hidden : 0,
minWidth: showText ? dimensions.hidden : 0,
}}
>
<TextWrapper ref={hiddenObserver.ref}>{text}</TextWrapper>
</TextHide>
</Row>
</IconBlock>
)
}
export default IconButton
......@@ -19105,10 +19105,10 @@ use-callback-ref@^1.2.1, use-callback-ref@^1.2.3:
resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.5.tgz"
integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==
use-resize-observer@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.0.2.tgz#25830221933d9b6e931850023305eb9d24379a6b"
integrity sha512-JOzsmF3/IDmtjG7OE5qXOP69LEpBpwhpLSiT1XgSr+uFRX0ftJHQnDaP7Xq+uhbljLYkJt67sqsbnyXBjiY8ig==
use-resize-observer@^9.1.0:
version "9.1.0"
resolved "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c"
integrity sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==
dependencies:
"@juggle/resize-observer" "^3.3.1"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment