feat: focus and hover hooks (#3287)

* feat: add focus/hover hooks

* refactor: use focus/hover hooks
This commit is contained in:
Zach Pomerantz 2022-02-14 06:32:11 -08:00 committed by GitHub
parent 59c5989721
commit 7de63ab462
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 76 additions and 46 deletions

@ -1,6 +1,6 @@
import { Icon } from 'lib/icons'
import styled, { Color } from 'lib/theme'
import { ComponentProps } from 'react'
import { ComponentProps, forwardRef } from 'react'
export const BaseButton = styled.button`
background-color: transparent;
@ -55,10 +55,12 @@ interface IconButtonProps {
iconProps?: ComponentProps<Icon>
}
export function IconButton({ icon: Icon, iconProps, ...props }: IconButtonProps & ComponentProps<typeof BaseButton>) {
return (
<SecondaryButton {...props}>
<Icon {...iconProps} />
</SecondaryButton>
)
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps & ComponentProps<typeof BaseButton>>(
function IconButton({ icon: Icon, iconProps, ...props }: IconButtonProps & ComponentProps<typeof BaseButton>, ref) {
return (
<SecondaryButton {...props} ref={ref}>
<Icon {...iconProps} />
</SecondaryButton>
)
}
)

@ -2,12 +2,12 @@ import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useAtom } from 'jotai'
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 { AlertTriangle, Check, Icon, LargeIcon, XOctagon } from 'lib/icons'
import { autoSlippageAtom, MAX_VALID_SLIPPAGE, maxSlippageAtom, MIN_HIGH_SLIPPAGE } from 'lib/state/settings'
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 Column from '../../Column'
@ -33,30 +33,28 @@ const Custom = styled(BaseButton)<{ selected: boolean }>`
padding: calc(0.75em - 3px) 0.625em;
`
interface OptionProps extends Partial<TooltipHandlers> {
interface OptionProps {
wrapper: typeof Button | typeof Custom
selected: boolean
onSelect: () => void
icon?: ReactNode
tabIndex?: number
children: ReactNode
}
function Option({
wrapper: Wrapper,
children,
selected,
onSelect,
icon,
...tooltipHandlers
}: PropsWithChildren<OptionProps>) {
const Option = forwardRef<HTMLButtonElement, OptionProps>(function Option(
{ wrapper: Wrapper, children, selected, onSelect, icon, tabIndex }: OptionProps,
ref
) {
return (
<Wrapper selected={selected} onClick={onSelect} {...tooltipHandlers}>
<Wrapper selected={selected} onClick={onSelect} ref={ref} tabIndex={tabIndex}>
<Row gap={0.5}>
{children}
{icon ? icon : <LargeIcon icon={selected ? Check : undefined} size={1.25} />}
</Row>
</Wrapper>
)
}
})
enum WarningState {
INVALID_SLIPPAGE = 1,
@ -105,15 +103,16 @@ const Warning = memo(function Warning({ state, showTooltip }: { state: WarningSt
})
export default function MaxSlippageSelect() {
const input = useRef<HTMLInputElement>(null)
const focus = useCallback(() => input.current?.focus(), [input])
const [autoSlippage, setAutoSlippage] = useAtom(autoSlippageAtom)
const [maxSlippage, setMaxSlippage] = useAtom(maxSlippageAtom)
const maxSlippageInput = useMemo(() => maxSlippage?.toString() || '', [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(
(value: number | undefined) => {
@ -144,7 +143,8 @@ export default function MaxSlippageSelect() {
selected={!autoSlippage}
onSelect={onInputSelect}
icon={warning && <Warning state={warning} showTooltip={showTooltip} />}
{...tooltipProps}
ref={option}
tabIndex={-1}
>
<Row color={warning === WarningState.INVALID_SLIPPAGE ? 'error' : undefined}>
<DecimalInput

@ -7,6 +7,7 @@ import useSyncConvenienceFee from 'lib/hooks/swap/useSyncConvenienceFee'
import useSyncSwapDefaults from 'lib/hooks/swap/useSyncSwapDefaults'
import { usePendingTransactions } from 'lib/hooks/transactions'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import useHasFocus from 'lib/hooks/useHasFocus'
import useTokenList from 'lib/hooks/useTokenList'
import { displayTxHashAtom } from 'lib/state/swap'
import { SwapTransactionInfo, Transaction, TransactionType } from 'lib/state/transactions'
@ -54,7 +55,7 @@ export default function Swap(props: SwapProps) {
useSyncConvenienceFee(props)
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 pendingTxs = usePendingTransactions()
@ -65,7 +66,7 @@ export default function Swap(props: SwapProps) {
[chainId, list]
)
const [focused, setFocused] = useState(false)
const focused = useHasFocus(wrapper)
return (
<SwapPropValidator {...props}>
@ -74,8 +75,8 @@ export default function Swap(props: SwapProps) {
{active && <Wallet disabled={!account} onClick={props.onConnectWallet} />}
<Settings disabled={!active} />
</Header>
<div onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} ref={setBoundary}>
<BoundaryProvider value={boundary}>
<div ref={setWrapper}>
<BoundaryProvider value={wrapper}>
<Input disabled={!active} focused={focused} />
<ReverseButton disabled={!active} />
<Output disabled={!active} focused={focused}>

@ -1,23 +1,17 @@
import { Placement } from '@popperjs/core'
import useHasFocus from 'lib/hooks/useHasFocus'
import useHasHover from 'lib/hooks/useHasHover'
import { HelpCircle, Icon } from 'lib/icons'
import styled from 'lib/theme'
import { ComponentProps, ReactNode, useCallback, useState } from 'react'
import { ComponentProps, ReactNode, useRef } from 'react'
import { IconButton } from './Button'
import Popover from './Popover'
export interface TooltipHandlers {
onMouseEnter: () => void
onMouseLeave: () => void
onFocus: () => void
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 }]
export function useTooltip(tooltip: Node | null | undefined): boolean {
const hover = useHasHover(tooltip)
const focus = useHasFocus(tooltip)
return hover || focus
}
const IconTooltip = styled(IconButton)`
@ -41,10 +35,11 @@ export default function Tooltip({
offset,
contained,
}: TooltipProps) {
const [showTooltip, , tooltipProps] = useTooltip()
const tooltip = useRef<HTMLDivElement>(null)
const showTooltip = useTooltip(tooltip.current)
return (
<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>
)
}

@ -0,0 +1,16 @@
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
}

@ -0,0 +1,16 @@
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
}