Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 140 additions & 114 deletions apps/explorer/src/comps/ExploreInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -17,6 +18,7 @@ export function ExploreInput(props: ExploreInput.Props) {
const {
onActivate,
inputRef: externalInputRef,
wrapperRef: externalWrapperRef,
value,
onChange,
size = 'medium',
Expand All @@ -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 &&
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -133,6 +144,7 @@ export function ExploreInput(props: ExploreInput.Props) {

const handleSelect = React.useCallback(
(result: SearchResult) => {
submittingRef.current = true
setShowResults(false)
setSelectedIndex(-1)

Expand All @@ -158,126 +170,138 @@ export function ExploreInput(props: ExploreInput.Props) {
)

return (
<form
ref={formRef}
onSubmit={(event) => {
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
<div className={cx('relative z-10 w-full', !wide && 'max-w-md')}>
<div
ref={externalWrapperRef}
className="overflow-hidden"
style={
externalWrapperRef ? { opacity: 0, pointerEvents: 'none' } : undefined
}
}}
className={cx('relative z-10 w-full', !wide && 'max-w-md')}
>
<input
ref={inputRef}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
tabIndex={tabIndex}
value={value}
disabled={disabled}
className={cx(
'bg-surface border-base-border border pl-[16px] pr-[60px] w-full placeholder:text-tertiary text-base-content rounded-[10px] focus-visible:border-focus outline-0 disabled:cursor-not-allowed disabled:opacity-50',
size === 'large' ? 'h-[52px] text-[17px]' : 'h-[42px] text-[15px]',
className,
)}
data-1p-ignore
name="value"
placeholder="Enter an address, token or transaction…"
spellCheck={false}
type="text"
onKeyDown={(event) => {
if (event.key === 'Escape' && showResults) {
>
<form
ref={formRef}
onSubmit={(event) => {
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)"
/>
<div
className={cx(
'absolute top-[50%] -translate-y-[50%]',
size === 'large' ? 'right-[16px]' : 'right-[12px]',
)}
>
<button
type="submit"
disabled={disabled || !isValidInput}
aria-label="Search"
className={cx(
'rounded-full! flex items-center justify-center active:translate-y-[0.5px] disabled:cursor-not-allowed transition-colors',
size === 'large' ? 'size-[28px]' : 'size-[24px]',
isValidInput
? 'bg-accent text-base-plane cursor-pointer'
: 'bg-base-alt text-tertiary cursor-default',
)}

if (Hex.validate(formValue) && Hex.size(formValue) === 32) {
onActivate?.({ type: 'hash', value: formValue })
return
}
}}
className="relative w-full"
>
<ArrowRight
className={size === 'large' ? 'size-[16px]' : 'size-[14px]'}
<input
ref={inputRef}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
tabIndex={tabIndex}
value={value}
disabled={disabled}
className={cx(
'bg-surface border-base-border border pl-[16px] pr-[60px] w-full placeholder:text-tertiary text-base-content rounded-[10px] focus-visible:border-focus outline-0 disabled:cursor-not-allowed disabled:opacity-50',
size === 'large'
? 'h-[52px] text-[17px]'
: 'h-[42px] text-[15px]',
className,
)}
data-1p-ignore
name="value"
placeholder="Enter an address, token or transaction…"
spellCheck={false}
type="text"
onKeyDown={(event) => {
if (event.key === 'Escape' && showResults) {
event.preventDefault()
setShowResults(false)
setSelectedIndex(-1)
return
}

if (!showResults || flatSuggestions.length === 0) return

if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((prev) =>
prev < flatSuggestions.length - 1 ? prev + 1 : 0,
)
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])
}
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)"
/>
</button>
<div
className={cx(
'absolute top-[50%] -translate-y-[50%]',
size === 'large' ? 'right-[16px]' : 'right-[12px]',
)}
>
<button
type="submit"
disabled={disabled || !isValidInput}
aria-label="Search"
className={cx(
'rounded-full! flex items-center justify-center active:translate-y-[0.5px] disabled:cursor-not-allowed transition-colors',
size === 'large' ? 'size-[28px]' : 'size-[24px]',
isValidInput
? 'bg-accent text-base-plane cursor-pointer'
: 'bg-base-alt text-tertiary cursor-default',
)}
>
<ArrowRight
className={size === 'large' ? 'size-[16px]' : 'size-[14px]'}
/>
</button>
</div>
</form>
</div>

{showResults && (
{menuMounted && (
<div
ref={resultsRef}
id={resultsId}
Expand All @@ -288,6 +312,7 @@ export function ExploreInput(props: ExploreInput.Props) {
'bg-surface border border-base-border rounded-[10px] overflow-hidden',
'shadow-[0px_4px_44px_rgba(0,0,0,0.05)]',
)}
style={{ opacity: 0 }}
>
<ProgressLine
loading={isFetching}
Expand Down Expand Up @@ -345,7 +370,7 @@ export function ExploreInput(props: ExploreInput.Props) {
)}
</div>
)}
</form>
</div>
)
}

Expand All @@ -360,6 +385,7 @@ export namespace ExploreInput {
| { value: Hex.Hex; type: 'hash' },
) => void
inputRef?: React.RefObject<HTMLInputElement | null>
wrapperRef?: React.RefObject<HTMLDivElement | null>
value: string
onChange: (value: string) => void
size?: 'large' | 'medium'
Expand Down Expand Up @@ -399,16 +425,16 @@ export namespace ExploreInput {
>
{suggestion.type === 'token' && (
<>
<div className="flex items-center gap-[10px] min-w-0 flex-1">
<div className="flex items-center gap-[10px] min-w-0 shrink">
<span className="text-[16px] font-medium text-base-content truncate">
{suggestion.name}
</span>
<span className="text-[11px] font-medium text-base-content bg-border-primary p-[4px] rounded-[4px] shrink-0">
{suggestion.symbol}
</span>
</div>
<span className="text-[13px] font-mono text-accent">
<Midcut value={suggestion.address} prefix="0x" />
<span className="text-[13px] font-mono text-accent flex-1 text-right">
<Midcut value={suggestion.address} prefix="0x" align="end" />
</span>
</>
)}
Expand Down
Loading