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
57 changes: 57 additions & 0 deletions components/address-autocomplete-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import {
type AutocompleteOption,
AutocompleteTextInput,
type AutocompleteTextInputProps,
} from "./autocomplete-text-input.js";

export interface AddressData {
address: string;
alias?: string;
wallet?: string;
}

export interface AddressInputProps
extends Omit<AutocompleteTextInputProps, "fetchOptions" | "onOptionSelect"> {
fetchAddresses: (query: string) => Promise<AddressData[]>;
chainId?: number;
}

export interface AddressAutoCompleteTextInputProps extends AddressInputProps {}

export const AddressAutoCompleteTextInput = ({
fetchAddresses,
placeholder = "Search address",
emptyMessage = "No addresses found.",
onChange,
...props
}: AddressAutoCompleteTextInputProps) => {
const fetchOptions = useCallback(
async (query: string): Promise<AutocompleteOption[]> => {
try {
const addresses = await fetchAddresses(query);
return addresses.map((addr) => ({
value: addr.address,
label: addr.alias,
badge: addr.wallet,
}));
} catch (error) {
console.error("Error fetching addresses:", error);
return [];
}
},
[fetchAddresses],
);

return (
<AutocompleteTextInput
fetchOptions={fetchOptions}
onChange={onChange}
placeholder={placeholder}
emptyMessage={emptyMessage}
{...props}
/>
);
};

export const AddressInput = AddressAutoCompleteTextInput;
160 changes: 160 additions & 0 deletions components/autocomplete-text-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useState } from "react";
import { isAddress } from "viem";
import { cn, truncateHex } from "../lib/utils.js";
import { Badge } from "./shadcn/badge.js";
import { Button } from "./shadcn/button.js";
import { Input, type InputProps } from "./shadcn/input.js";

export interface AutocompleteOption {
value: string;
label?: string;
badge?: string | React.ReactNode;
}

export interface AutocompleteTextInputProps
extends Omit<InputProps, "onChange" | "onSelect" | "onBlur"> {
value?: string;
onChange?: (value: string) => void;
fetchOptions: (query: string) => Promise<AutocompleteOption[]>;
placeholder?: string;
emptyMessage?: string;
minQueryLength?: number;
}

export const AutocompleteTextInput = ({
value = "",
onChange,
fetchOptions,
placeholder = "Type to search...",
emptyMessage = "No results found.",
minQueryLength = 0,
className,
...inputProps
}: AutocompleteTextInputProps) => {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<AutocompleteOption[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState(value);

const fetchData = useCallback(
async (searchQuery: string) => {
if (searchQuery.length < minQueryLength) {
setOptions([]);
return;
}

setLoading(true);
try {
const results = await fetchOptions(searchQuery);
setOptions(results);
} catch (error) {
console.error("Error fetching autocomplete options:", error);
setOptions([]);
} finally {
setLoading(false);
}
},
[fetchOptions, minQueryLength],
);

useEffect(() => {
fetchData(query);
}, [query, fetchData]);

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setQuery(newValue);
onChange?.(newValue);
setOpen(true);
};

const handleInputClick = () => {
setOpen(true);

if (options.length === 0 && !loading) {
fetchData(query);
}
};

const handleSelect = (option: AutocompleteOption) => {
setQuery(option.value);
onChange?.(option.value);
setOpen(false);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};

return (
<div className="autocomplete-container relative w-full">
<Input
value={query}
onChange={handleInputChange}
onClick={handleInputClick}
onBlur={() => {
setOpen(false);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={cn("w-full", className)}
autoComplete="off"
{...inputProps}
/>
{open && (
<div className="absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-hidden overflow-y-auto rounded-md border bg-popover shadow-md">
{loading ? (
<div className="px-3 py-2 text-muted-foreground text-sm">
Loading...
</div>
) : options.length === 0 ? (
<div className="px-3 py-2 text-muted-foreground text-sm">
{emptyMessage}
</div>
) : (
<div>
{options.map((option) => (
<Button
key={option.value}
variant="ghost"
asChild
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
console.log("Option selected:", option);
handleSelect(option);
}}
className="flex h-16 w-full justify-start rounded-none border-border border-b px-4 py-3 last:border-b-0"
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium text-sm leading-none">
{option.label ?? displayValue(option.value)}
</span>
{option.label && (
<span className="truncate font-mono text-muted-foreground text-sm leading-none">
{displayValue(option.value)}
</span>
)}
</div>
{option.badge && (
<Badge variant="secondary" className="shrink-0">
{option.badge}
</Badge>
)}
</div>
</Button>
))}
</div>
)}
</div>
)}
</div>
);
};

function displayValue(value: string) {
return isAddress(value) ? truncateHex(value) : value;
}
88 changes: 88 additions & 0 deletions components/form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ interface Props<T extends FieldValues>
onSubmit: SubmitHandler<T>;
}

import {
AddressAutoCompleteTextInput as AddressAutoCompleteInput,
type AddressData,
} from "../address-autocomplete-input.js";
import {
AutocompleteTextInput as AutoCompleteInput,
type AutocompleteOption,
} from "../autocomplete-text-input.js";
import { AutoSubmitSwitch } from "./auto-submit/switch";
import { AutoSubmitTextInput } from "./auto-submit/text-input";

Expand Down Expand Up @@ -413,3 +421,83 @@ function SelectInput<
);
}
Form.Select = SelectInput;

interface AddressInputFormProps<T extends FieldValues>
extends BaseInputProps<T> {
onAddressSelect?: (addressData: AddressData) => void;
fetchAddresses: (query: string) => Promise<AddressData[]>;
}

interface AutoCompleteTextInputFormProps<T extends FieldValues>
extends BaseInputProps<T> {
fetchOptions: (query: string) => Promise<AutocompleteOption[]>;
onOptionSelect?: (option: AutocompleteOption) => void;
}

function AddressAutoCompleteTextInput<T extends FieldValues>({
name,
label,
className = "",
onAddressSelect,
fetchAddresses,
...rest
}: AddressInputFormProps<T>) {
const { control } = useFormContext();

return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("w-full", className)}>
<FormLabel>{label}</FormLabel>
<FormControl>
<AddressAutoCompleteInput
{...rest}
value={field.value}
onChange={field.onChange}
name={field.name}
fetchAddresses={fetchAddresses}
/>
</FormControl>
<FormMessage>&nbsp;</FormMessage>
</FormItem>
)}
/>
);
}
Form.AddressAutoCompleteTextInput = AddressAutoCompleteTextInput;

function AutoCompleteTextInput<T extends FieldValues>({
name,
label,
className = "",
onOptionSelect,
fetchOptions,
...rest
}: AutoCompleteTextInputFormProps<T>) {
const { control } = useFormContext();

return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className={cn("w-full", className)}>
<FormLabel>{label}</FormLabel>
<FormControl>
<AutoCompleteInput
{...rest}
value={field.value}
onChange={field.onChange}
name={field.name}
fetchOptions={fetchOptions}
/>
</FormControl>
<FormMessage>&nbsp;</FormMessage>
</FormItem>
)}
/>
);
}
Form.AutoCompleteTextInput = AutoCompleteTextInput;
4 changes: 4 additions & 0 deletions lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export function decodeDefaultArgs(
}
}

export function truncateHex(address: string): string {
return `${address.slice(0, 6)}…${address.slice(-4)}`;
}

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Loading