feat: focus and hover hooks (#3287)
* feat: add focus/hover hooks * refactor: use focus/hover hooks
This commit is contained in:
parent
59c5989721
commit
7de63ab462
@ -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>
|
||||
)
|
||||
}
|
||||
|
16
src/lib/hooks/useHasFocus.ts
Normal file
16
src/lib/hooks/useHasFocus.ts
Normal file
@ -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
|
||||
}
|
16
src/lib/hooks/useHasHover.ts
Normal file
16
src/lib/hooks/useHasHover.ts
Normal file
@ -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
|
||||
}
|
Loading…
Reference in New Issue
Block a user