Commit 8a9388ed authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

feat: make Expando element (#3515)

* feat: make Expando element

* fix: cleanup

* fix: simplify margin

* fix: summary height

* fix: special case gap transition
parent 884bf41d
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import useScrollbar from 'lib/hooks/useScrollbar' import ActionButton from 'lib/components/ActionButton'
import { AlertTriangle, Expando, Icon, Info, LargeIcon } from 'lib/icons' import Column from 'lib/components/Column'
import Expando from 'lib/components/Expando'
import { AlertTriangle, Icon, LargeIcon } from 'lib/icons'
import styled, { Color, ThemedText } from 'lib/theme' import styled, { Color, ThemedText } from 'lib/theme'
import { ReactNode, useState } from 'react' import { ReactNode, useCallback, useState } from 'react'
import ActionButton from '../ActionButton'
import { IconButton } from '../Button'
import Column from '../Column'
import Row from '../Row'
import Rule from '../Rule'
const HeaderIcon = styled(LargeIcon)` const HeaderIcon = styled(LargeIcon)`
flex-grow: 1; flex-grow: 1;
...@@ -35,7 +31,6 @@ export function StatusHeader({ icon: Icon, iconColor, iconSize = 4, children }: ...@@ -35,7 +31,6 @@ export function StatusHeader({ icon: Icon, iconColor, iconSize = 4, children }:
{children} {children}
</Column> </Column>
</Column> </Column>
<Rule />
</> </>
) )
} }
...@@ -49,40 +44,6 @@ const ErrorHeader = styled(Column)<{ open: boolean }>` ...@@ -49,40 +44,6 @@ const ErrorHeader = styled(Column)<{ open: boolean }>`
transition: max-height 0.25s; transition: max-height 0.25s;
} }
` `
const ErrorColumn = styled(Column)``
const ExpandoColumn = styled(Column)<{ open: boolean }>`
flex-grow: ${({ open }) => (open ? 2 : 0)};
transition: flex-grow 0.25s, gap 0.25s;
${Rule} {
margin-bottom: ${({ open }) => (open ? 0 : 0.75)}em;
transition: margin-bottom 0.25s;
}
${ErrorColumn} {
flex-basis: 0;
flex-grow: ${({ open }) => (open ? 1 : 0)};
overflow-y: hidden;
position: relative;
transition: flex-grow 0.25s;
${Column} {
height: 6.825em;
padding: ${({ open }) => (open ? '0.5em 0' : 0)};
transition: padding 0.25s;
:after {
background: linear-gradient(#ffffff00, ${({ theme }) => theme.dialog});
bottom: 0;
content: '';
height: 0.75em;
pointer-events: none;
position: absolute;
width: calc(100% - 1em);
}
}
}
`
interface ErrorDialogProps { interface ErrorDialogProps {
header?: ReactNode header?: ReactNode
...@@ -93,8 +54,8 @@ interface ErrorDialogProps { ...@@ -93,8 +54,8 @@ interface ErrorDialogProps {
export default function ErrorDialog({ header, error, action, onClick }: ErrorDialogProps) { export default function ErrorDialog({ header, error, action, onClick }: ErrorDialogProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [details, setDetails] = useState<HTMLDivElement | null>(null) const onExpand = useCallback(() => setOpen((open) => !open), [])
const scrollbar = useScrollbar(details)
return ( return (
<Column flex padded gap={0.75} align="stretch" style={{ height: '100%' }}> <Column flex padded gap={0.75} align="stretch" style={{ height: '100%' }}>
<StatusHeader icon={AlertTriangle} iconColor="error" iconSize={open ? 3 : 4}> <StatusHeader icon={AlertTriangle} iconColor="error" iconSize={open ? 3 : 4}>
...@@ -105,27 +66,15 @@ export default function ErrorDialog({ header, error, action, onClick }: ErrorDia ...@@ -105,27 +66,15 @@ export default function ErrorDialog({ header, error, action, onClick }: ErrorDia
<ThemedText.Body2>{header}</ThemedText.Body2> <ThemedText.Body2>{header}</ThemedText.Body2>
</ErrorHeader> </ErrorHeader>
</StatusHeader> </StatusHeader>
<Row> <Column gap={open ? 0 : 0.75} style={{ transition: 'gap 0.25s' }}>
<Row gap={0.5}> <Expando title={<Trans>Error details</Trans>} open={open} onExpand={onExpand} height={7.5}>
<Info color="secondary" /> <ThemedText.Code userSelect>
<ThemedText.Subhead2 color="secondary"> {error.name}
<Trans>Error details</Trans> {error.message ? `: ${error.message}` : ''}
</ThemedText.Subhead2> </ThemedText.Code>
</Row> </Expando>
<IconButton color="secondary" onClick={() => setOpen(!open)} icon={Expando} iconProps={{ open }} />
</Row>
<ExpandoColumn flex align="stretch" open={open}>
<Rule />
<ErrorColumn>
<Column gap={0.5} ref={setDetails} css={scrollbar}>
<ThemedText.Code userSelect>
{error.name}
{error.message ? `: ${error.message}` : ''}
</ThemedText.Code>
</Column>
</ErrorColumn>
<ActionButton onClick={onClick}>{action}</ActionButton> <ActionButton onClick={onClick}>{action}</ActionButton>
</ExpandoColumn> </Column>
</Column> </Column>
) )
} }
import { IconButton } from 'lib/components/Button'
import Column from 'lib/components/Column'
import Row from 'lib/components/Row'
import Rule from 'lib/components/Rule'
import useScrollbar from 'lib/hooks/useScrollbar'
import { Expando as ExpandoIcon } from 'lib/icons'
import styled from 'lib/theme'
import { PropsWithChildren, ReactNode, useState } from 'react'
const HeaderColumn = styled(Column)`
transition: gap 0.25s;
`
const ExpandoColumn = styled(Column)<{ height: number; open: boolean }>`
height: ${({ height, open }) => (open ? height : 0)}em;
overflow: hidden;
position: relative;
transition: height 0.25s, padding 0.25s;
:after {
background: linear-gradient(#ffffff00, ${({ theme }) => theme.dialog});
bottom: 0;
content: '';
height: 0.75em;
pointer-events: none;
position: absolute;
width: calc(100% - 1em);
}
`
const InnerColumn = styled(Column)<{ height: number }>`
height: ${({ height }) => height}em;
padding: 0.5em 0;
`
interface ExpandoProps {
title: ReactNode
open: boolean
onExpand: () => void
// The absolute height of the expanded container, in em.
height: number
}
/** A scrollable Expando with an absolute height. */
export default function Expando({ title, open, onExpand, height, children }: PropsWithChildren<ExpandoProps>) {
const [scrollingEl, setScrollingEl] = useState<HTMLDivElement | null>(null)
const scrollbar = useScrollbar(scrollingEl)
return (
<Column>
<HeaderColumn gap={open ? 0.5 : 0.75}>
<Rule />
<Row>
{title}
<IconButton color="secondary" onClick={onExpand} icon={ExpandoIcon} iconProps={{ open }} />
</Row>
<Rule />
</HeaderColumn>
<ExpandoColumn open={open} height={height}>
<InnerColumn flex align="stretch" height={height} ref={setScrollingEl} css={scrollbar}>
{children}
</InnerColumn>
</ExpandoColumn>
</Column>
)
return null
}
...@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react' ...@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk' import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core' import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import Column from 'lib/components/Column'
import Row from 'lib/components/Row'
import { feeOptionsAtom } from 'lib/state/swap' import { feeOptionsAtom } from 'lib/state/swap'
import styled, { Color, ThemedText } from 'lib/theme' import styled, { Color, ThemedText } from 'lib/theme'
import { useMemo } from 'react' import { useMemo } from 'react'
...@@ -10,8 +12,6 @@ import { currencyId } from 'utils/currencyId' ...@@ -10,8 +12,6 @@ import { currencyId } from 'utils/currencyId'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { computeRealizedLPFeeAmount } from 'utils/prices' import { computeRealizedLPFeeAmount } from 'utils/prices'
import Row from '../../Row'
const Value = styled.span<{ color?: Color }>` const Value = styled.span<{ color?: Color }>`
color: ${({ color, theme }) => color && theme[color]}; color: ${({ color, theme }) => color && theme[color]};
white-space: nowrap; white-space: nowrap;
...@@ -97,10 +97,10 @@ export default function Details({ trade, slippage, usdcPriceImpact }: DetailsPro ...@@ -97,10 +97,10 @@ export default function Details({ trade, slippage, usdcPriceImpact }: DetailsPro
]) ])
return ( return (
<> <Column gap={0.5}>
{details.map(([label, detail, color]) => ( {details.map(([label, detail, color]) => (
<Detail key={label} label={label} value={detail} color={color} /> <Detail key={label} label={label} value={detail} color={color} />
))} ))}
</> </Column>
) )
} }
...@@ -3,18 +3,15 @@ import { useLingui } from '@lingui/react' ...@@ -3,18 +3,15 @@ import { useLingui } from '@lingui/react'
import { Trade } from '@uniswap/router-sdk' import { Trade } from '@uniswap/router-sdk'
import { Currency, TradeType } from '@uniswap/sdk-core' import { Currency, TradeType } from '@uniswap/sdk-core'
import ActionButton, { Action } from 'lib/components/ActionButton' import ActionButton, { Action } from 'lib/components/ActionButton'
import { IconButton } from 'lib/components/Button'
import Column from 'lib/components/Column' import Column from 'lib/components/Column'
import { Header } from 'lib/components/Dialog' import { Header } from 'lib/components/Dialog'
import Expando from 'lib/components/Expando'
import Row from 'lib/components/Row' import Row from 'lib/components/Row'
import Rule from 'lib/components/Rule'
import { useSwapTradeType } from 'lib/hooks/swap'
import useScrollbar from 'lib/hooks/useScrollbar'
import { Slippage } from 'lib/hooks/useSlippage' import { Slippage } from 'lib/hooks/useSlippage'
import useUSDCPriceImpact from 'lib/hooks/useUSDCPriceImpact' import useUSDCPriceImpact from 'lib/hooks/useUSDCPriceImpact'
import { AlertTriangle, BarChart, Expando, Info } from 'lib/icons' import { AlertTriangle, BarChart, Info } from 'lib/icons'
import styled, { Color, ThemedText } from 'lib/theme' import styled, { Color, ThemedText } from 'lib/theme'
import { useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' import { formatCurrencyAmount } from 'utils/formatCurrencyAmount'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer' import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
...@@ -24,60 +21,27 @@ import Summary from './Summary' ...@@ -24,60 +21,27 @@ import Summary from './Summary'
export default Summary export default Summary
const SummaryColumn = styled(Column)`` const Content = styled(Column)``
const ExpandoColumn = styled(Column)`` const Heading = styled(Column)``
const DetailsColumn = styled(Column)`` const Footing = styled(Column)``
const Estimate = styled(ThemedText.Caption)``
const Body = styled(Column)<{ open: boolean }>` const Body = styled(Column)<{ open: boolean }>`
height: calc(100% - 2.5em); height: calc(100% - 2.5em);
${SummaryColumn} { ${Content}, ${Heading} {
flex-grow: ${({ open }) => (open ? 0 : 1)}; flex-grow: 1;
transition: flex-grow 0.25s; transition: flex-grow 0.25s;
} }
${ExpandoColumn} { ${Footing} {
flex-grow: ${({ open }) => (open ? 1 : 0)}; margin-bottom: ${({ open }) => (open ? '-0.75em' : undefined)};
transition: flex-grow 0.25s; max-height: ${({ open }) => (open ? 0 : '3em')};
opacity: ${({ open }) => (open ? 0 : 1)};
${DetailsColumn} { transition: max-height 0.25s, margin-bottom 0.25s, opacity 0.15s 0.1s;
flex-basis: ${({ open }) => (open ? 6.75 : 0)}em; visibility: ${({ open }) => (open ? 'hidden' : undefined)};
overflow-y: hidden;
position: relative;
transition: flex-basis 0.25s;
${Column} {
height: 6.75em;
grid-template-rows: repeat(auto-fill, 1em);
padding: ${({ open }) => (open ? '0.5em 0' : 0)};
transition: padding 0.25s;
:after {
background: linear-gradient(#ffffff00, ${({ theme }) => theme.dialog});
bottom: 0;
content: '';
height: 0.75em;
pointer-events: none;
position: absolute;
width: calc(100% - 1em);
}
}
}
${Estimate} {
max-height: ${({ open }) => (open ? 0 : 56 / 12)}em; // 2 * line-height + padding
min-height: 0;
overflow-y: hidden;
padding: ${({ open }) => (open ? 0 : '1em 0')};
transition: ${({ open }) =>
open
? 'max-height 0.1s ease-out, padding 0.25s ease-out'
: 'flex-grow 0.25s ease-out, max-height 0.1s ease-in, padding 0.25s ease-out'};
}
} }
` `
function Subhead({ priceImpact, slippage }: { priceImpact: { warning?: Color }; slippage: { warning?: Color } }) { function Subhead({ priceImpact, slippage }: { priceImpact: { warning?: Color }; slippage: Slippage }) {
return ( return (
<Row gap={0.5}> <Row gap={0.5}>
{priceImpact.warning || slippage.warning ? ( {priceImpact.warning || slippage.warning ? (
...@@ -98,41 +62,55 @@ function Subhead({ priceImpact, slippage }: { priceImpact: { warning?: Color }; ...@@ -98,41 +62,55 @@ function Subhead({ priceImpact, slippage }: { priceImpact: { warning?: Color };
) )
} }
interface SummaryDialogProps { function Estimate({ trade, slippage }: { trade: Trade<Currency, Currency, TradeType>; slippage: Slippage }) {
trade: Trade<Currency, Currency, TradeType>
slippage: Slippage
onConfirm: () => void
}
export function SummaryDialog({ trade, slippage, onConfirm }: SummaryDialogProps) {
const { inputAmount, outputAmount } = trade
const inputCurrency = inputAmount.currency
const outputCurrency = outputAmount.currency
const usdcPriceImpact = useUSDCPriceImpact(inputAmount, outputAmount)
const tradeType = useSwapTradeType()
const { i18n } = useLingui() const { i18n } = useLingui()
const text = useMemo(() => {
switch (trade.tradeType) {
case TradeType.EXACT_INPUT:
return (
<Trans>
Output is estimated. You will receive at least{' '}
{formatCurrencyAmount(trade.minimumAmountOut(slippage.allowed), 6, i18n.locale)}{' '}
{trade.outputAmount.currency.symbol} or the transaction will revert.
</Trans>
)
case TradeType.EXACT_OUTPUT:
return (
<Trans>
Output is estimated. You will send at most{' '}
{formatCurrencyAmount(trade.maximumAmountIn(slippage.allowed), 6, i18n.locale)}{' '}
{trade.inputAmount.currency.symbol} or the transaction will revert.
</Trans>
)
}
}, [i18n.locale, slippage.allowed, trade])
return <ThemedText.Caption color="secondary">{text}</ThemedText.Caption>
}
const [open, setOpen] = useState(false) function ConfirmButton({
const [details, setDetails] = useState<HTMLDivElement | null>(null) trade,
const scrollbar = useScrollbar(details) highPriceImpact,
onConfirm,
}: {
trade: Trade<Currency, Currency, TradeType>
highPriceImpact: boolean
onConfirm: () => void
}) {
const [ackPriceImpact, setAckPriceImpact] = useState(false) const [ackPriceImpact, setAckPriceImpact] = useState(false)
const [ackTrade, setAckTrade] = useState(trade)
const [confirmedTrade, setConfirmedTrade] = useState(trade)
const doesTradeDiffer = useMemo( const doesTradeDiffer = useMemo(
() => Boolean(trade && confirmedTrade && tradeMeaningfullyDiffers(trade, confirmedTrade)), () => Boolean(trade && ackTrade && tradeMeaningfullyDiffers(trade, ackTrade)),
[confirmedTrade, trade] [ackTrade, trade]
) )
const action = useMemo((): Action | undefined => { const action = useMemo((): Action | undefined => {
if (doesTradeDiffer) { if (doesTradeDiffer) {
return { return {
message: <Trans>Price updated</Trans>, message: <Trans>Price updated</Trans>,
icon: BarChart, icon: BarChart,
onClick: () => setConfirmedTrade(trade), onClick: () => setAckTrade(trade),
children: <Trans>Accept</Trans>, children: <Trans>Accept</Trans>,
} }
} else if (usdcPriceImpact.warning === 'error' && !ackPriceImpact) { } else if (highPriceImpact && !ackPriceImpact) {
return { return {
message: <Trans>High price impact</Trans>, message: <Trans>High price impact</Trans>,
onClick: () => setAckPriceImpact(true), onClick: () => setAckPriceImpact(true),
...@@ -140,52 +118,50 @@ export function SummaryDialog({ trade, slippage, onConfirm }: SummaryDialogProps ...@@ -140,52 +118,50 @@ export function SummaryDialog({ trade, slippage, onConfirm }: SummaryDialogProps
} }
} }
return return
}, [ackPriceImpact, doesTradeDiffer, trade, usdcPriceImpact.warning]) }, [ackPriceImpact, doesTradeDiffer, highPriceImpact, trade])
if (!(inputAmount && outputAmount && inputCurrency && outputCurrency)) { return (
return null <ActionButton onClick={onConfirm} action={action}>
} <Trans>Confirm swap</Trans>
</ActionButton>
)
}
interface SummaryDialogProps {
trade: Trade<Currency, Currency, TradeType>
slippage: Slippage
onConfirm: () => void
}
export function SummaryDialog({ trade, slippage, onConfirm }: SummaryDialogProps) {
const { inputAmount, outputAmount } = trade
const usdcPriceImpact = useUSDCPriceImpact(inputAmount, outputAmount)
const [open, setOpen] = useState(false)
const onExpand = useCallback(() => setOpen((open) => !open), [])
return ( return (
<> <>
<Header title={<Trans>Swap summary</Trans>} ruled /> <Header title={<Trans>Swap summary</Trans>} ruled />
<Body flex align="stretch" gap={0.75} padded open={open}> <Body flex align="stretch" padded gap={0.75} open={open}>
<SummaryColumn gap={0.75} flex justify="center"> <Heading gap={0.75} flex justify="center">
<Summary input={inputAmount} output={outputAmount} usdcPriceImpact={usdcPriceImpact} /> <Summary input={inputAmount} output={outputAmount} usdcPriceImpact={usdcPriceImpact} />
<Price trade={trade} /> <Price trade={trade} />
</SummaryColumn> </Heading>
<Rule /> <Column gap={open ? 0 : 0.75} style={{ transition: 'gap 0.25s' }}>
<Row> <Expando
<Subhead priceImpact={usdcPriceImpact} slippage={slippage} /> title={<Subhead priceImpact={usdcPriceImpact} slippage={slippage} />}
<IconButton color="secondary" onClick={() => setOpen(!open)} icon={Expando} iconProps={{ open }} /> open={open}
</Row> onExpand={onExpand}
<ExpandoColumn flex align="stretch"> height={7.25}
<Rule /> >
<DetailsColumn> <Details trade={trade} slippage={slippage} usdcPriceImpact={usdcPriceImpact} />
<Column gap={0.5} ref={setDetails} css={scrollbar}> </Expando>
<Details trade={trade} slippage={slippage} usdcPriceImpact={usdcPriceImpact} /> <Footing>
</Column> <Estimate trade={trade} slippage={slippage} />
</DetailsColumn> </Footing>
<Estimate color="secondary"> <ConfirmButton trade={trade} highPriceImpact={usdcPriceImpact.warning === 'error'} onConfirm={onConfirm} />
<Trans>Output is estimated.</Trans>{' '} </Column>
{tradeType === TradeType.EXACT_INPUT && (
<Trans>
You will receive at least{' '}
{formatCurrencyAmount(trade.minimumAmountOut(slippage.allowed), 6, i18n.locale)} {outputCurrency.symbol}{' '}
or the transaction will revert.
</Trans>
)}
{tradeType === TradeType.EXACT_OUTPUT && (
<Trans>
You will send at most {formatCurrencyAmount(trade.maximumAmountIn(slippage.allowed), 6, i18n.locale)}{' '}
{inputCurrency.symbol} or the transaction will revert.
</Trans>
)}
</Estimate>
<ActionButton onClick={onConfirm} action={action}>
<Trans>Confirm swap</Trans>
</ActionButton>
</ExpandoColumn>
</Body> </Body>
</> </>
) )
......
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