import { Code, Wallet } from '@mui/icons-material' import clsx from 'clsx' import { FormEvent, useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { FieldValues, Path, useFormContext } from 'react-hook-form' import { AddressInputProps, EntityType } from '@dao-dao/types' import { getAccountAddress, isValidBech32Address } from '@dao-dao/utils' import { useChain } from '../../contexts/Chain' import { useTrackDropdown } from '../../hooks/useTrackDropdown' import { EntityDisplay as StatelessEntityDisplay } from '../EntityDisplay' import { Loader } from '../logo/Loader' export const AddressInput = < FV extends FieldValues, FieldName extends Path, >({ fieldName, register, watch: _watch, setValue: _setValue, error, validation, onChange, disabled, required, className, containerClassName, type, EntityDisplay, autofillEntities, placeholder, hideEntity, ...rest }: AddressInputProps) => { const { chainId, bech32Prefix } = useChain() const validate = validation?.reduce( (a, v) => ({ ...a, [v.toString()]: v }), {} ) // Default to wallet icon. const Icon = type === 'contract' ? Code : Wallet // Null if not within a FormProvider. const formContext = useFormContext() const watch = _watch || formContext?.watch const setValue = _setValue || formContext?.setValue const formValue = fieldName ? watch?.(fieldName) : rest.value const showEntity = !hideEntity && EntityDisplay && !!formValue && typeof formValue === 'string' && isValidBech32Address(formValue, bech32Prefix) const inputRef = useRef(null) const inputRegistration = register?.((fieldName ?? '') as any, { required: required && 'Required', validate, onChange, }) const [inputFocused, setInputFocused] = useState(false) const autofillEntityContainerRef = useRef(null) const [selectedEntityIndex, setSelectedEntityIndex] = useState(0) // Ensure selected index stays valid. useEffect(() => { setSelectedEntityIndex((prev) => Math.min(prev, autofillEntities?.entities.length ?? 0) ) }, [autofillEntities]) // Scroll to selected entity. useEffect(() => { if (!autofillEntityContainerRef.current || selectedEntityIndex === -1) { return } const selected = autofillEntityContainerRef.current.children[ selectedEntityIndex ] as HTMLDivElement | undefined if (!selected) { return } // If selected entity is not in view, scroll to it. if ( selected.offsetTop < autofillEntityContainerRef.current.scrollTop || selected.offsetTop + selected.clientHeight > autofillEntityContainerRef.current.scrollTop + autofillEntityContainerRef.current.clientHeight ) { selected.scrollIntoView({ behavior: 'smooth' }) } }, [selectedEntityIndex]) // Only show auto fill dropdown if there are entities to show. const showEntityAutoFill = inputFocused && autofillEntities && autofillEntities.entities.length > 0 const selectAutofillEntity = useCallback( (index?: number) => { index ??= selectedEntityIndex if ( !autofillEntities || index < 0 || index >= autofillEntities.entities.length ) { return } const selectedEntity = autofillEntities.entities[index] // Get address on current chain. const address = selectedEntity.chainId === chainId ? selectedEntity.address : selectedEntity.type === EntityType.Dao && getAccountAddress({ accounts: selectedEntity.daoInfo.accounts, chainId, }) if (!address) { return } setValue?.((fieldName ?? '') as any, address as any, { shouldValidate: true, shouldDirty: true, shouldTouch: true, }) inputRef.current?.blur() }, [autofillEntities, chainId, fieldName, selectedEntityIndex, setValue] ) // Navigate between selected entities with arrow keys. useEffect(() => { // If not showing entity autofill, do not process keypresses. if (!showEntityAutoFill || !autofillEntities) { return } const handleKeyPress = (event: KeyboardEvent) => { switch (event.key) { case 'Escape': event.preventDefault() inputRef.current?.blur() break case 'ArrowUp': event.preventDefault() setSelectedEntityIndex((index) => index - 1 < 0 ? autofillEntities.entities.length - 1 : // Just in case for some reason the index is overflowing. Math.min(index - 1, autofillEntities.entities.length - 1) ) break case 'ArrowDown': case 'Tab': event.preventDefault() setSelectedEntityIndex( // Just in case for some reason the index is underflowing. (index) => Math.max(index + 1, 0) % autofillEntities.entities.length ) break case 'Enter': event.preventDefault() selectAutofillEntity() break } } document.addEventListener('keydown', handleKeyPress) // Clean up event listener on unmount. return () => document.removeEventListener('keydown', handleKeyPress) }, [autofillEntities, selectAutofillEntity, showEntityAutoFill]) // Only display entity if input is disabled and we're showing the entity. This // is probably showing in a readonly form with submitted data. const onlyDisplayEntity = disabled && showEntity // Track container to position the autofill dropdown. const { onDropdownRef, onTrackRef } = useTrackDropdown({ top: (rect) => rect.bottom + 2, left: (rect) => rect.left - 2, width: (rect) => rect.width + 4, padding: 12, }) return (
{!onlyDisplayEntity && ( <> {/* If entities are loading, display loader. */} {autofillEntities?.loading ? ( ) : ( )} ) => setValue?.( (fieldName ?? '') as any, e.currentTarget.value as any ) } placeholder={ placeholder || // If contract, use chain prefix. (type === 'contract' ? `${bech32Prefix}...` : undefined) } type="text" {...rest} {...inputRegistration} onBlur={ // Timeout to allow click event to happen on entity autofill row. () => setTimeout(() => setInputFocused(false), 100) } onFocus={() => setInputFocused(true)} ref={(ref) => { inputRegistration?.ref(ref) inputRef.current = ref }} /> )} {showEntity && ( )} {!disabled && !!autofillEntities && createPortal(
{autofillEntities.entities.map((entity, index) => (
selectAutofillEntity(index)} >
))}
, document.body )}
) }