diff --git a/apps/explorer/src/comps/ExploreInput.tsx b/apps/explorer/src/comps/ExploreInput.tsx index 8a5fc84fb..74c36f9c9 100644 --- a/apps/explorer/src/comps/ExploreInput.tsx +++ b/apps/explorer/src/comps/ExploreInput.tsx @@ -2,6 +2,7 @@ import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query' import { Address, Hex } from 'ox' import * as React from 'react' import { Midcut } from '#comps/Midcut' +import { useMountAnim } from '#lib/animation' import { ProgressLine } from '#comps/ProgressLine' import { RelativeTime } from '#comps/RelativeTime' import { cx } from '#cva.config' @@ -17,6 +18,7 @@ export function ExploreInput(props: ExploreInput.Props) { const { onActivate, inputRef: externalInputRef, + wrapperRef: externalWrapperRef, value, onChange, size = 'medium', @@ -33,8 +35,13 @@ export function ExploreInput(props: ExploreInput.Props) { const [showResults, setShowResults] = React.useState(false) const [selectedIndex, setSelectedIndex] = React.useState(-1) + const menuMounted = useMountAnim(showResults, resultsRef) const resultsId = React.useId() + // prevents the menu from reopening when + // activating a menu item fills the input + const submittingRef = React.useRef(false) + const query = value.trim() const isValidInput = query.length > 0 && @@ -91,6 +98,10 @@ export function ExploreInput(props: ExploreInput.Props) { ) React.useEffect(() => { + if (submittingRef.current) { + submittingRef.current = false + return + } setShowResults(disabled ? false : query.length > 0) }, [query, disabled]) @@ -133,6 +144,7 @@ export function ExploreInput(props: ExploreInput.Props) { const handleSelect = React.useCallback( (result: SearchResult) => { + submittingRef.current = true setShowResults(false) setSelectedIndex(-1) @@ -158,126 +170,138 @@ export function ExploreInput(props: ExploreInput.Props) { ) return ( -
{ - event.preventDefault() - if (!formRef.current || disabled) return - - const data = new FormData(formRef.current) - let formValue = data.get('value') - if (!formValue || typeof formValue !== 'string') return - - formValue = formValue.trim() - if (!formValue) return - - if (Address.validate(formValue)) { - onActivate?.({ type: 'address', value: formValue }) - return - } - - if (Hex.validate(formValue) && Hex.size(formValue) === 32) { - onActivate?.({ type: 'hash', value: formValue }) - return +
+
- { - if (event.key === 'Escape' && showResults) { + > + { event.preventDefault() - setShowResults(false) - setSelectedIndex(-1) - return - } + if (!formRef.current || disabled) return - if (!showResults || flatSuggestions.length === 0) return + const data = new FormData(formRef.current) + let formValue = data.get('value') + if (!formValue || typeof formValue !== 'string') return - if (event.key === 'ArrowDown') { - event.preventDefault() - setSelectedIndex((prev) => - prev < flatSuggestions.length - 1 ? prev + 1 : 0, - ) - return - } + formValue = formValue.trim() + if (!formValue) return - if (event.key === 'ArrowUp') { - event.preventDefault() - setSelectedIndex((prev) => - prev > 0 ? prev - 1 : flatSuggestions.length - 1, - ) - return - } - - if (event.key === 'Enter') { - const index = selectedIndex >= 0 ? selectedIndex : 0 - if (index < flatSuggestions.length) { - event.preventDefault() - handleSelect(flatSuggestions[index]) + if (Address.validate(formValue)) { + onActivate?.({ type: 'address', value: formValue }) + return } - return - } - }} - onChange={(event) => { - onChange?.(event.target.value) - }} - onFocus={() => { - if (query.length > 0 && flatSuggestions.length > 0) - setShowResults(true) - }} - role="combobox" - aria-expanded={showResults} - aria-haspopup="listbox" - aria-autocomplete="list" - aria-controls={resultsId} - aria-activedescendant={ - selectedIndex !== -1 ? `${resultsId}-${selectedIndex}` : undefined - } - title="Enter an address, token or transaction to explore (Cmd+K to focus)" - /> -
- +
+ +
+
- {showResults && ( + {menuMounted && (
)} - +
) } @@ -360,6 +385,7 @@ export namespace ExploreInput { | { value: Hex.Hex; type: 'hash' }, ) => void inputRef?: React.RefObject + wrapperRef?: React.RefObject value: string onChange: (value: string) => void size?: 'large' | 'medium' @@ -399,7 +425,7 @@ export namespace ExploreInput { > {suggestion.type === 'token' && ( <> -
+
{suggestion.name} @@ -407,8 +433,8 @@ export namespace ExploreInput { {suggestion.symbol}
- - + + )} diff --git a/apps/explorer/src/lib/animation.ts b/apps/explorer/src/lib/animation.ts index 36b197ae6..7b7cb9ffb 100644 --- a/apps/explorer/src/lib/animation.ts +++ b/apps/explorer/src/lib/animation.ts @@ -1,10 +1,11 @@ -import { spring } from 'animejs' +import { animate, spring } from 'animejs' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' // near instant, e.g. for user interactions export const springInstant = spring({ mass: 1, - stiffness: 1200, - damping: 38, + stiffness: 1400, + damping: 40, }) // use to put emphasis on an element, @@ -28,3 +29,68 @@ export const springLazy = spring({ stiffness: 220, damping: 50, }) + +const defaultEnter = { + opacity: [0, 1], + scale: [0.99, 1], + translateY: [-4, 0], + ease: springInstant, +} + +const defaultExit = { + opacity: [1, 0], + scale: [1, 0.99], + ease: springInstant, +} + +export function useMountAnim( + open: boolean, + ref: React.RefObject, + options: { + enter?: Parameters[1] + exit?: Parameters[1] + } = {}, +) { + const [mounted, setMounted] = useState(false) + const { enter = defaultEnter, exit = defaultExit } = options + const prevOpenRef = useRef(open) + const prevMountedRef = useRef(mounted) + const closingRef = useRef(false) + + useEffect(() => { + const wasOpen = prevOpenRef.current + prevOpenRef.current = open + + // opening + if (open && !wasOpen) { + if (closingRef.current && ref.current) { + // interrupt close animation + closingRef.current = false + animate(ref.current, enter) + } else { + setMounted(true) + } + } + // closing + else if (!open && wasOpen && mounted && ref.current) { + closingRef.current = true + animate(ref.current, exit).then(() => { + if (closingRef.current) { + setMounted(false) + closingRef.current = false + } + }) + } + }, [open, mounted, ref, enter, exit]) + + useLayoutEffect(() => { + const wasMounted = prevMountedRef.current + prevMountedRef.current = mounted + + if (open && mounted && !wasMounted && ref.current) { + animate(ref.current, enter) + } + }, [mounted, open, ref, enter]) + + return mounted +} diff --git a/apps/explorer/src/routes/_layout/index.tsx b/apps/explorer/src/routes/_layout/index.tsx index 596914316..dca22ab44 100644 --- a/apps/explorer/src/routes/_layout/index.tsx +++ b/apps/explorer/src/routes/_layout/index.tsx @@ -71,8 +71,9 @@ function Component() { const introSeenOnMount = React.useRef(introSeen) const [inputValue, setInputValue] = React.useState('') const [isMounted, setIsMounted] = React.useState(false) - const inputWrapperRef = React.useRef(null) + const [inputReady, setInputReady] = React.useState(false) const exploreInputRef = React.useRef(null) + const exploreWrapperRef = React.useRef(null) const isNavigating = useRouterState({ select: (state) => state.status === 'pending', }) @@ -86,14 +87,20 @@ function Component() { }, [router]) const handlePhaseChange = React.useCallback((phase: IntroPhase) => { - if (phase === 'start' && inputWrapperRef.current) { + if (phase === 'start' && exploreWrapperRef.current) { const seen = introSeenOnMount.current - animate(inputWrapperRef.current, { + animate(exploreWrapperRef.current, { opacity: [0, 1], scale: [seen ? 0.97 : 0.94, 1], ease: seen ? springInstant : springBouncy, delay: seen ? 0 : 240, - onBegin: () => exploreInputRef.current?.focus(), + onBegin: () => { + setInputReady(true) + if (exploreWrapperRef.current) { + exploreWrapperRef.current.style.pointerEvents = 'auto' + } + exploreInputRef.current?.focus() + }, }) } }, []) @@ -102,43 +109,39 @@ function Component() {
-
-
- { - if (data.type === 'hash') { - navigate({ - to: '/receipt/$hash', - params: { hash: data.value }, - }) - return - } - if (data.type === 'token') { - navigate({ - to: '/token/$address', - params: { address: data.value }, - }) - return - } - if (data.type === 'address') { - navigate({ - to: '/address/$address', - params: { address: data.value }, - }) - return - } - }} - /> -
+
+ { + if (data.type === 'hash') { + navigate({ + to: '/receipt/$hash', + params: { hash: data.value }, + }) + return + } + if (data.type === 'token') { + navigate({ + to: '/token/$address', + params: { address: data.value }, + }) + return + } + if (data.type === 'address') { + navigate({ + to: '/address/$address', + params: { address: data.value }, + }) + return + } + }} + />
@@ -195,14 +198,16 @@ function SpotlightLinks() { if (!pillsRef.current) return const seen = introSeenOnMount.current const children = [...pillsRef.current.children] - for (const child of children) { - ;(child as HTMLElement).style.pointerEvents = 'auto' - } const anim = animate(children, { opacity: [0, 1], translateY: [seen ? 2 : 4, 0], ease: seen ? springInstant : springSmooth, delay: seen ? stagger(10) : stagger(20, { start: 320, from: 'random' }), + onBegin: () => { + for (const child of children) { + ;(child as HTMLElement).style.pointerEvents = 'auto' + } + }, }) anim.then(() => { for (const child of children) {