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', () => { ...@@ -45,6 +45,9 @@ describe('Wallet Dropdown', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/') cy.visit('/')
cy.get(getTestSelector('web3-status-connected')).click() 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-disconnect')).click()
cy.get(getTestSelector('wallet-settings')).click() cy.get(getTestSelector('wallet-settings')).click()
}) })
......
...@@ -17,7 +17,7 @@ import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hoo ...@@ -17,7 +17,7 @@ import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hoo
import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable' import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable'
import { ProfilePageStateType } from 'nft/types' import { ProfilePageStateType } from 'nft/types'
import { useCallback, useState } from 'react' 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 { useNavigate } from 'react-router-dom'
import { shouldDisableNFTRoutesAtom } from 'state/application/atoms' import { shouldDisableNFTRoutesAtom } from 'state/application/atoms'
import { useAppDispatch } from 'state/hooks' import { useAppDispatch } from 'state/hooks'
...@@ -31,7 +31,7 @@ import { ApplicationModal } from '../../state/application/reducer' ...@@ -31,7 +31,7 @@ import { ApplicationModal } from '../../state/application/reducer'
import { useUserHasAvailableClaim, useUserUnclaimedAmount } from '../../state/claim/hooks' import { useUserHasAvailableClaim, useUserUnclaimedAmount } from '../../state/claim/hooks'
import StatusIcon from '../Identicon/StatusIcon' import StatusIcon from '../Identicon/StatusIcon'
import { useToggleAccountDrawer } from '.' import { useToggleAccountDrawer } from '.'
import IconButton, { IconHoverText } from './IconButton' import IconButton, { IconHoverText, IconWithConfirmTextButton } from './IconButton'
import MiniPortfolio from './MiniPortfolio' import MiniPortfolio from './MiniPortfolio'
import { portfolioFadeInAnimation } from './MiniPortfolio/PortfolioRow' import { portfolioFadeInAnimation } from './MiniPortfolio/PortfolioRow'
...@@ -103,7 +103,9 @@ const FiatOnrampAvailabilityExternalLink = styled(ExternalLink)` ...@@ -103,7 +103,9 @@ const FiatOnrampAvailabilityExternalLink = styled(ExternalLink)`
const StatusWrapper = styled.div` const StatusWrapper = styled.div`
display: inline-block; display: inline-block;
width: 70%; width: 70%;
padding-right: 4px; max-width: 70%;
overflow: hidden;
padding-right: 14px;
display: inline-flex; display: inline-flex;
` `
...@@ -158,6 +160,10 @@ export function PortfolioArrow({ change, ...rest }: { change: number } & IconPro ...@@ -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 }) { export default function AuthenticatedHeader({ account, openSettings }: { account: string; openSettings: () => void }) {
const { connector, ENSName } = useWeb3React() const { connector, ENSName } = useWeb3React()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
...@@ -232,6 +238,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account ...@@ -232,6 +238,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
const totalBalance = portfolio?.tokensTotalDenominatedValue?.value const totalBalance = portfolio?.tokensTotalDenominatedValue?.value
const absoluteChange = portfolio?.tokensTotalDenominatedValueChange?.absolute?.value const absoluteChange = portfolio?.tokensTotalDenominatedValueChange?.absolute?.value
const percentChange = portfolio?.tokensTotalDenominatedValueChange?.percentage?.value const percentChange = portfolio?.tokensTotalDenominatedValueChange?.percentage?.value
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false)
return ( return (
<AuthenticatedHeaderWrapper> <AuthenticatedHeaderWrapper>
...@@ -253,13 +260,21 @@ export default function AuthenticatedHeader({ account, openSettings }: { account ...@@ -253,13 +260,21 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
)} )}
</StatusWrapper> </StatusWrapper>
<IconContainer> <IconContainer>
<IconButton data-testid="wallet-settings" onClick={openSettings} Icon={Settings} /> {!showDisconnectConfirm && (
<IconButton data-testid="wallet-settings" onClick={openSettings} Icon={Settings} />
)}
<TraceEvent <TraceEvent
events={[BrowserEvent.onClick]} events={[BrowserEvent.onClick]}
name={SharedEventName.ELEMENT_CLICKED} name={SharedEventName.ELEMENT_CLICKED}
element={InterfaceElementName.DISCONNECT_WALLET_BUTTON} 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> </TraceEvent>
</IconContainer> </IconContainer>
</HeaderWrapper> </HeaderWrapper>
......
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { Icon } from 'react-feather' import { Icon } from 'react-feather'
import styled, { css } from 'styled-components/macro' import styled, { css } from 'styled-components/macro'
import useResizeObserver from 'use-resize-observer'
import Row from '../Row'
export const IconHoverText = styled.span` export const IconHoverText = styled.span`
color: ${({ theme }) => theme.textPrimary}; color: ${({ theme }) => theme.textPrimary};
...@@ -13,12 +17,17 @@ export const IconHoverText = styled.span` ...@@ -13,12 +17,17 @@ export const IconHoverText = styled.span`
left: 10px; left: 10px;
` `
const widthTransition = `width ease-in 80ms`
const IconStyles = css` const IconStyles = css`
background-color: ${({ theme }) => theme.backgroundInteractive}; background-color: ${({ theme }) => theme.backgroundInteractive};
transition: ${widthTransition};
border-radius: 12px; border-radius: 12px;
display: inline-block; display: flex;
padding: 0;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
overflow: hidden;
height: 32px; height: 32px;
width: 32px; width: 32px;
color: ${({ theme }) => theme.textPrimary}; color: ${({ theme }) => theme.textPrimary};
...@@ -28,7 +37,7 @@ const IconStyles = css` ...@@ -28,7 +37,7 @@ const IconStyles = css`
theme: { theme: {
transition: { duration, timing }, transition: { duration, timing },
}, },
}) => `${duration.fast} background-color ${timing.in}`}; }) => `${duration.fast} background-color ${timing.in}, ${widthTransition}`};
${IconHoverText} { ${IconHoverText} {
opacity: 1; opacity: 1;
...@@ -36,7 +45,7 @@ const IconStyles = css` ...@@ -36,7 +45,7 @@ const IconStyles = css`
} }
:active { :active {
background-color: ${({ theme }) => theme.backgroundSurface}; background-color: ${({ theme }) => theme.backgroundSurface};
transition: background-color 50ms linear; transition: background-color 50ms linear, ${widthTransition};
} }
` `
...@@ -51,27 +60,29 @@ const IconBlockButton = styled.button` ...@@ -51,27 +60,29 @@ const IconBlockButton = styled.button`
` `
const IconWrapper = styled.span` const IconWrapper = styled.span`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 16px; width: 16px;
height: 16px; height: 16px;
margin: auto;
display: flex;
` `
interface BaseProps { interface BaseProps {
Icon: Icon Icon: Icon
children?: React.ReactNode
} }
interface IconLinkProps extends React.ComponentPropsWithoutRef<'a'>, BaseProps {} interface IconLinkProps extends React.ComponentPropsWithoutRef<'a'>, BaseProps {}
interface IconButtonProps extends React.ComponentPropsWithoutRef<'button'>, 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) { 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 // ignoring 'button' 'type' conflict between React and styled-components
// @ts-ignore // @ts-ignore
return <IconBlockButton {...props} /> return <IconBlockButton ref={ref} {...props} />
} })
const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => ( const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => (
<IconBlock {...rest}> <IconBlock {...rest}>
...@@ -81,4 +92,119 @@ const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => ( ...@@ -81,4 +92,119 @@ const IconButton = ({ Icon, ...rest }: IconButtonProps | IconLinkProps) => (
</IconBlock> </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 export default IconButton
...@@ -19105,10 +19105,10 @@ use-callback-ref@^1.2.1, use-callback-ref@^1.2.3: ...@@ -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" resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.5.tgz"
integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg== integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==
use-resize-observer@^9.0.2: use-resize-observer@^9.1.0:
version "9.0.2" version "9.1.0"
resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.0.2.tgz#25830221933d9b6e931850023305eb9d24379a6b" resolved "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c"
integrity sha512-JOzsmF3/IDmtjG7OE5qXOP69LEpBpwhpLSiT1XgSr+uFRX0ftJHQnDaP7Xq+uhbljLYkJt67sqsbnyXBjiY8ig== integrity sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==
dependencies: dependencies:
"@juggle/resize-observer" "^3.3.1" "@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