diff --git a/components/address-autocomplete-input.tsx b/components/address-autocomplete-input.tsx new file mode 100644 index 0000000..b0a0e56 --- /dev/null +++ b/components/address-autocomplete-input.tsx @@ -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 { + fetchAddresses: (query: string) => Promise; + 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 => { + 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 ( + + ); +}; + +export const AddressInput = AddressAutoCompleteTextInput; diff --git a/components/autocomplete-text-input.tsx b/components/autocomplete-text-input.tsx new file mode 100644 index 0000000..aed884a --- /dev/null +++ b/components/autocomplete-text-input.tsx @@ -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 { + value?: string; + onChange?: (value: string) => void; + fetchOptions: (query: string) => Promise; + 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([]); + 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) => { + 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 ( +
+ { + setOpen(false); + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className={cn("w-full", className)} + autoComplete="off" + {...inputProps} + /> + {open && ( +
+ {loading ? ( +
+ Loading... +
+ ) : options.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ )} +
+ ); +}; + +function displayValue(value: string) { + return isAddress(value) ? truncateHex(value) : value; +} diff --git a/components/form/index.tsx b/components/form/index.tsx index d767e5a..cedb0fa 100644 --- a/components/form/index.tsx +++ b/components/form/index.tsx @@ -39,6 +39,14 @@ interface Props onSubmit: SubmitHandler; } +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"; @@ -413,3 +421,83 @@ function SelectInput< ); } Form.Select = SelectInput; + +interface AddressInputFormProps + extends BaseInputProps { + onAddressSelect?: (addressData: AddressData) => void; + fetchAddresses: (query: string) => Promise; +} + +interface AutoCompleteTextInputFormProps + extends BaseInputProps { + fetchOptions: (query: string) => Promise; + onOptionSelect?: (option: AutocompleteOption) => void; +} + +function AddressAutoCompleteTextInput({ + name, + label, + className = "", + onAddressSelect, + fetchAddresses, + ...rest +}: AddressInputFormProps) { + const { control } = useFormContext(); + + return ( + ( + + {label} + + + +   + + )} + /> + ); +} +Form.AddressAutoCompleteTextInput = AddressAutoCompleteTextInput; + +function AutoCompleteTextInput({ + name, + label, + className = "", + onOptionSelect, + fetchOptions, + ...rest +}: AutoCompleteTextInputFormProps) { + const { control } = useFormContext(); + + return ( + ( + + {label} + + + +   + + )} + /> + ); +} +Form.AutoCompleteTextInput = AutoCompleteTextInput; diff --git a/lib/utils.tsx b/lib/utils.tsx index 259a5f0..1fb37cf 100644 --- a/lib/utils.tsx +++ b/lib/utils.tsx @@ -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)); } diff --git a/stories/forms/AddressAutoCompleteTextInput.stories.tsx b/stories/forms/AddressAutoCompleteTextInput.stories.tsx new file mode 100644 index 0000000..bc65acb --- /dev/null +++ b/stories/forms/AddressAutoCompleteTextInput.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useForm } from "react-hook-form"; +import { Form } from "../../components/form/index.js"; + +const mockAddresses = [ + { + address: "0x0E801D84Fa97b50751Dbf25036d067dCf18858bF", + alias: "My Wallet", + wallet: "MyWallet", + }, + { + address: "0x9d4454B023096f34B160D6B654540c56A1F81688", + alias: "Trading Account", + }, + { + address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + alias: "Vitalik.eth", + wallet: "Vitalik", + }, + { + address: "0x8f86403A4DE0BB5791fa46B8e795C547942fE4Cf", + alias: "DEX Contract", + }, + { address: "0x1234567890123456789012345678901234567890" }, + { + address: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + alias: "Test Wallet", + wallet: "Test", + }, +]; + +const mockFetchAddresses = async (query: string) => { + if (!query.trim()) { + return mockAddresses; + } + + return mockAddresses.filter( + (addr) => + addr.address.toLowerCase().includes(query.toLowerCase()) || + addr.alias?.toLowerCase().includes(query.toLowerCase()), + ); +}; + +const meta = { + title: "Components/Form/AddressAutoCompleteTextInput", + component: Form.AddressAutoCompleteTextInput, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => { + const form = useForm({ defaultValues: { recipient: "" } }); + return ( +
console.log("submitted")}> + + + ); + }, + ], + + args: { + name: "recipient", + label: "Recipient Address", + placeholder: "Search addresses...", + fetchAddresses: mockFetchAddresses, + className: "w-full", + }, +}; + +export const WithForm: Story = { + decorators: [ + (Story) => { + const form = useForm({ + defaultValues: { + recipient: "", + amount: "", + }, + }); + + const onSubmit = (data: any) => { + console.log("Form submitted:", data); + }; + + return ( +
+ + + + + ); + }, + ], + + args: { + name: "recipient", + label: "Recipient Address", + placeholder: "Search addresses...", + fetchAddresses: mockFetchAddresses, + className: "w-full", + }, +}; diff --git a/stories/forms/AutoCompleteTextInput.stories.tsx b/stories/forms/AutoCompleteTextInput.stories.tsx new file mode 100644 index 0000000..5778bcf --- /dev/null +++ b/stories/forms/AutoCompleteTextInput.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useForm } from "react-hook-form"; +import { Form } from "../../components/form/index.js"; + +const fetchStatuses = async (query: string) => { + const statuses = [ + { value: "draft", label: "Draft" }, + { value: "review", label: "In Review" }, + { value: "approved", label: "Approved" }, + { value: "published", label: "Published" }, + { value: "archived", label: "Archived" }, + ]; + + if (!query) return statuses; + + return statuses.filter((s) => + s.label.toLowerCase().includes(query.toLowerCase()), + ); +}; + +const meta = { + title: "Components/Form/AutoCompleteTextInput", + component: Form.AutoCompleteTextInput, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => { + const form = useForm({ defaultValues: { status: "" } }); + return ( +
console.log("submitted")}> + + + ); + }, + ], + + args: { + name: "status", + label: "Document Status", + placeholder: "Choose status...", + fetchOptions: fetchStatuses, + className: "w-full", + }, +};