Commit 7de63ab4 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

feat: focus and hover hooks (#3287)

* feat: add focus/hover hooks

* refactor: use focus/hover hooks
parent 59c59897
import { Icon } from 'lib/icons' import { Icon } from 'lib/icons'
import styled, { Color } from 'lib/theme' import styled, { Color } from 'lib/theme'
import { ComponentProps } from 'react' import { ComponentProps, forwardRef } from 'react'
export const BaseButton = styled.button` export const BaseButton = styled.button`
background-color: transparent; background-color: transparent;
...@@ -55,10 +55,12 @@ interface IconButtonProps { ...@@ -55,10 +55,12 @@ interface IconButtonProps {
iconProps?: ComponentProps<Icon> iconProps?: ComponentProps<Icon>
} }
export function IconButton({ icon: Icon, iconProps, ...props }: IconButtonProps & ComponentProps<typeof BaseButton>) { export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps & ComponentProps<typeof BaseButton>>(
return ( function IconButton({ icon: Icon, iconProps, ...props }: IconButtonProps & ComponentProps<typeof BaseButton>, ref) {
<SecondaryButton {...props}> return (
<Icon {...iconProps} /> <SecondaryButton {...props} ref={ref}>
</SecondaryButton> <Icon {...iconProps} />
) </SecondaryButton>
} )
}
)
...@@ -2,12 +2,12 @@ import { Trans } from '@lingui/macro' ...@@ -2,12 +2,12 @@ import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core' import { Percent } from '@uniswap/sdk-core'
import { useAtom } from 'jotai' import { useAtom } from 'jotai'
import Popover from 'lib/components/Popover' import Popover from 'lib/components/Popover'
import { TooltipHandlers, useTooltip } from 'lib/components/Tooltip' import { useTooltip } from 'lib/components/Tooltip'
import { toPercent } from 'lib/hooks/useAllowedSlippage' import { toPercent } from 'lib/hooks/useAllowedSlippage'
import { AlertTriangle, Check, Icon, LargeIcon, XOctagon } from 'lib/icons' import { AlertTriangle, Check, Icon, LargeIcon, XOctagon } from 'lib/icons'
import { autoSlippageAtom, MAX_VALID_SLIPPAGE, maxSlippageAtom, MIN_HIGH_SLIPPAGE } from 'lib/state/settings' import { autoSlippageAtom, MAX_VALID_SLIPPAGE, maxSlippageAtom, MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
import styled, { Color, ThemedText } from 'lib/theme' import styled, { Color, ThemedText } from 'lib/theme'
import { memo, PropsWithChildren, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, memo, ReactNode, useCallback, useMemo, useRef, useState } from 'react'
import { BaseButton, TextButton } from '../../Button' import { BaseButton, TextButton } from '../../Button'
import Column from '../../Column' import Column from '../../Column'
...@@ -33,30 +33,28 @@ const Custom = styled(BaseButton)<{ selected: boolean }>` ...@@ -33,30 +33,28 @@ const Custom = styled(BaseButton)<{ selected: boolean }>`
padding: calc(0.75em - 3px) 0.625em; padding: calc(0.75em - 3px) 0.625em;
` `
interface OptionProps extends Partial<TooltipHandlers> { interface OptionProps {
wrapper: typeof Button | typeof Custom wrapper: typeof Button | typeof Custom
selected: boolean selected: boolean
onSelect: () => void onSelect: () => void
icon?: ReactNode icon?: ReactNode
tabIndex?: number
children: ReactNode
} }
function Option({ const Option = forwardRef<HTMLButtonElement, OptionProps>(function Option(
wrapper: Wrapper, { wrapper: Wrapper, children, selected, onSelect, icon, tabIndex }: OptionProps,
children, ref
selected, ) {
onSelect,
icon,
...tooltipHandlers
}: PropsWithChildren<OptionProps>) {
return ( return (
<Wrapper selected={selected} onClick={onSelect} {...tooltipHandlers}> <Wrapper selected={selected} onClick={onSelect} ref={ref} tabIndex={tabIndex}>
<Row gap={0.5}> <Row gap={0.5}>
{children} {children}
{icon ? icon : <LargeIcon icon={selected ? Check : undefined} size={1.25} />} {icon ? icon : <LargeIcon icon={selected ? Check : undefined} size={1.25} />}
</Row> </Row>
</Wrapper> </Wrapper>
) )
} })
enum WarningState { enum WarningState {
INVALID_SLIPPAGE = 1, INVALID_SLIPPAGE = 1,
...@@ -105,15 +103,16 @@ const Warning = memo(function Warning({ state, showTooltip }: { state: WarningSt ...@@ -105,15 +103,16 @@ const Warning = memo(function Warning({ state, showTooltip }: { state: WarningSt
}) })
export default function MaxSlippageSelect() { export default function MaxSlippageSelect() {
const input = useRef<HTMLInputElement>(null)
const focus = useCallback(() => input.current?.focus(), [input])
const [autoSlippage, setAutoSlippage] = useAtom(autoSlippageAtom) const [autoSlippage, setAutoSlippage] = useAtom(autoSlippageAtom)
const [maxSlippage, setMaxSlippage] = useAtom(maxSlippageAtom) const [maxSlippage, setMaxSlippage] = useAtom(maxSlippageAtom)
const maxSlippageInput = useMemo(() => maxSlippage?.toString() || '', [maxSlippage]) const maxSlippageInput = useMemo(() => maxSlippage?.toString() || '', [maxSlippage])
const [warning, setWarning] = useState<WarningState | undefined>(toWarningState(toPercent(maxSlippage))) const [warning, setWarning] = useState<WarningState | undefined>(toWarningState(toPercent(maxSlippage)))
const [showTooltip, setShowTooltip, tooltipProps] = useTooltip(/*showOnMount=*/ true)
useEffect(() => setShowTooltip(true), [warning, setShowTooltip]) // enables the tooltip when a warning is set const option = useRef<HTMLButtonElement>(null)
const showTooltip = useTooltip(option.current)
const input = useRef<HTMLInputElement>(null)
const focus = useCallback(() => input.current?.focus(), [input])
const processValue = useCallback( const processValue = useCallback(
(value: number | undefined) => { (value: number | undefined) => {
...@@ -144,7 +143,8 @@ export default function MaxSlippageSelect() { ...@@ -144,7 +143,8 @@ export default function MaxSlippageSelect() {
selected={!autoSlippage} selected={!autoSlippage}
onSelect={onInputSelect} onSelect={onInputSelect}
icon={warning && <Warning state={warning} showTooltip={showTooltip} />} icon={warning && <Warning state={warning} showTooltip={showTooltip} />}
{...tooltipProps} ref={option}
tabIndex={-1}
> >
<Row color={warning === WarningState.INVALID_SLIPPAGE ? 'error' : undefined}> <Row color={warning === WarningState.INVALID_SLIPPAGE ? 'error' : undefined}>
<DecimalInput <DecimalInput
......
...@@ -7,6 +7,7 @@ import useSyncConvenienceFee from 'lib/hooks/swap/useSyncConvenienceFee' ...@@ -7,6 +7,7 @@ import useSyncConvenienceFee from 'lib/hooks/swap/useSyncConvenienceFee'
import useSyncSwapDefaults from 'lib/hooks/swap/useSyncSwapDefaults' import useSyncSwapDefaults from 'lib/hooks/swap/useSyncSwapDefaults'
import { usePendingTransactions } from 'lib/hooks/transactions' import { usePendingTransactions } from 'lib/hooks/transactions'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React' import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import useHasFocus from 'lib/hooks/useHasFocus'
import useTokenList from 'lib/hooks/useTokenList' import useTokenList from 'lib/hooks/useTokenList'
import { displayTxHashAtom } from 'lib/state/swap' import { displayTxHashAtom } from 'lib/state/swap'
import { SwapTransactionInfo, Transaction, TransactionType } from 'lib/state/transactions' import { SwapTransactionInfo, Transaction, TransactionType } from 'lib/state/transactions'
...@@ -54,7 +55,7 @@ export default function Swap(props: SwapProps) { ...@@ -54,7 +55,7 @@ export default function Swap(props: SwapProps) {
useSyncConvenienceFee(props) useSyncConvenienceFee(props)
const { active, account, chainId } = useActiveWeb3React() const { active, account, chainId } = useActiveWeb3React()
const [boundary, setBoundary] = useState<HTMLDivElement | null>(null) const [wrapper, setWrapper] = useState<HTMLDivElement | null>(null)
const [displayTxHash, setDisplayTxHash] = useAtom(displayTxHashAtom) const [displayTxHash, setDisplayTxHash] = useAtom(displayTxHashAtom)
const pendingTxs = usePendingTransactions() const pendingTxs = usePendingTransactions()
...@@ -65,7 +66,7 @@ export default function Swap(props: SwapProps) { ...@@ -65,7 +66,7 @@ export default function Swap(props: SwapProps) {
[chainId, list] [chainId, list]
) )
const [focused, setFocused] = useState(false) const focused = useHasFocus(wrapper)
return ( return (
<SwapPropValidator {...props}> <SwapPropValidator {...props}>
...@@ -74,8 +75,8 @@ export default function Swap(props: SwapProps) { ...@@ -74,8 +75,8 @@ export default function Swap(props: SwapProps) {
{active && <Wallet disabled={!account} onClick={props.onConnectWallet} />} {active && <Wallet disabled={!account} onClick={props.onConnectWallet} />}
<Settings disabled={!active} /> <Settings disabled={!active} />
</Header> </Header>
<div onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} ref={setBoundary}> <div ref={setWrapper}>
<BoundaryProvider value={boundary}> <BoundaryProvider value={wrapper}>
<Input disabled={!active} focused={focused} /> <Input disabled={!active} focused={focused} />
<ReverseButton disabled={!active} /> <ReverseButton disabled={!active} />
<Output disabled={!active} focused={focused}> <Output disabled={!active} focused={focused}>
......
import { Placement } from '@popperjs/core' import { Placement } from '@popperjs/core'
import useHasFocus from 'lib/hooks/useHasFocus'
import useHasHover from 'lib/hooks/useHasHover'
import { HelpCircle, Icon } from 'lib/icons' import { HelpCircle, Icon } from 'lib/icons'
import styled from 'lib/theme' import styled from 'lib/theme'
import { ComponentProps, ReactNode, useCallback, useState } from 'react' import { ComponentProps, ReactNode, useRef } from 'react'
import { IconButton } from './Button' import { IconButton } from './Button'
import Popover from './Popover' import Popover from './Popover'
export interface TooltipHandlers { export function useTooltip(tooltip: Node | null | undefined): boolean {
onMouseEnter: () => void const hover = useHasHover(tooltip)
onMouseLeave: () => void const focus = useHasFocus(tooltip)
onFocus: () => void return hover || focus
onBlur: () => void
}
export function useTooltip(showOnMount = false): [boolean, (show: boolean) => void, TooltipHandlers] {
const [show, setShow] = useState(showOnMount)
const enable = useCallback(() => setShow(true), [])
const disable = useCallback(() => setShow(false), [])
return [show, setShow, { onMouseEnter: enable, onMouseLeave: disable, onFocus: enable, onBlur: disable }]
} }
const IconTooltip = styled(IconButton)` const IconTooltip = styled(IconButton)`
...@@ -41,10 +35,11 @@ export default function Tooltip({ ...@@ -41,10 +35,11 @@ export default function Tooltip({
offset, offset,
contained, contained,
}: TooltipProps) { }: TooltipProps) {
const [showTooltip, , tooltipProps] = useTooltip() const tooltip = useRef<HTMLDivElement>(null)
const showTooltip = useTooltip(tooltip.current)
return ( return (
<Popover content={children} show={showTooltip} placement={placement} offset={offset} contained={contained}> <Popover content={children} show={showTooltip} placement={placement} offset={offset} contained={contained}>
<IconTooltip icon={Icon} iconProps={iconProps} {...tooltipProps} /> <IconTooltip icon={Icon} iconProps={iconProps} ref={tooltip} />
</Popover> </Popover>
) )
} }
import { useCallback, useEffect, useState } from 'react'
export default function useHasFocus(node: Node | null | undefined): boolean {
const [hasFocus, setHasFocus] = useState(node?.contains(document.activeElement) ?? false)
const onFocus = useCallback(() => setHasFocus(true), [])
const onBlur = useCallback((e) => setHasFocus(node?.contains(e.relatedTarget) ?? false), [node])
useEffect(() => {
node?.addEventListener('focusin', onFocus)
node?.addEventListener('focusout', onBlur)
return () => {
node?.removeEventListener('focusin', onFocus)
node?.removeEventListener('focusout', onBlur)
}
}, [node, onFocus, onBlur])
return hasFocus
}
import { useCallback, useEffect, useState } from 'react'
export default function useHasHover(node: Node | null | undefined): boolean {
const [hasHover, setHasHover] = useState(false)
const onMouseEnter = useCallback(() => setHasHover(true), [])
const onMouseLeave = useCallback((e) => setHasHover(false), [])
useEffect(() => {
node?.addEventListener('mouseenter', onMouseEnter)
node?.addEventListener('mouseleave', onMouseLeave)
return () => {
node?.removeEventListener('mouseenter', onMouseEnter)
node?.removeEventListener('mouseleave', onMouseLeave)
}
}, [node, onMouseEnter, onMouseLeave])
return hasHover
}
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