Skip to content
This repository was archived by the owner on May 15, 2025. It is now read-only.

Commit aa5af30

Browse files
authored
Tx button & paginated select (#95)
* feat: add option to reset TransactionButton state on success * fix: remove console log from TransactionButton component * fix: update default value in Select component for better handling of empty states enforce controled state * feat: add support for custom values in Select component * wip: paginated select * fix: ensure default value in Select component is an empty string for better handling of undefined states * feat: implement searchable options in PaginatedSelect component * feat: enhance PaginatedSelect with modal and search functionality; refactor Select and InfiniteScroll components * feat: refactor PaginatedSelect, Select, and Input components for improved structure and error handling * fix: update modal state handling in PaginatedSelect and improve Input component class merging * fix: remove unnecessary console log from PaginatedSelect component * fix: adjust modal height in PaginatedSelect for better layout * fix: fix date input when selecting hours first * fix: add suffix prop to PaginatedSelect for enhanced customization
1 parent 9f730ca commit aa5af30

6 files changed

Lines changed: 531 additions & 95 deletions

File tree

src/components/dapp/TransactionButton.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type TransactionButtonProps = ButtonProps & {
2020
onSuccess?: (hash: string) => void;
2121
onError?: (hash: string) => void;
2222
onClick?: () => void;
23+
shouldResetOnSuccess?: boolean;
2324
};
2425

2526
export default function TransactionButton({
@@ -32,6 +33,7 @@ export default function TransactionButton({
3233
enableSponsorCheckbox,
3334
iconProps,
3435
onError,
36+
shouldResetOnSuccess,
3537
...props
3638
}: TransactionButtonProps) {
3739
const [status, setStatus] = useState<"idle" | "pending" | "success">("idle");
@@ -43,6 +45,7 @@ export default function TransactionButton({
4345
sponsorTransactions,
4446
setSponsorTransactions,
4547
} = useWalletContext();
48+
4649
const execute = useCallback(async () => {
4750
if (!tx || !user || !client) return;
4851

@@ -129,6 +132,7 @@ export default function TransactionButton({
129132
state: "good",
130133
loading: false,
131134
});
135+
if (!!shouldResetOnSuccess) setStatus("idle");
132136
}
133137
} catch (_error) {
134138
setStatus("idle");
@@ -140,7 +144,7 @@ export default function TransactionButton({
140144
loading: false,
141145
});
142146
}
143-
}, [tx, client, user, sendTransaction, onExecute, onSuccess, onError, name, onClick]);
147+
}, [tx, client, user, shouldResetOnSuccess, sendTransaction, onExecute, onSuccess, onError, name, onClick]);
144148

145149
//TODO: remove hardcoded chainId check in favor of more integrated and generic implem
146150
if (enableSponsorCheckbox && chainId === 324)
@@ -159,6 +163,7 @@ export default function TransactionButton({
159163
}
160164
</List>
161165
);
166+
162167
return (
163168
<Button {...props} onClick={execute} disabled={status === "idle" ? props.disabled : true}>
164169
{status === "pending" ? (
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import { tv } from "tailwind-variants";
3+
import useDebounce from "../../hooks/useDebounce";
4+
import { mergeClass } from "../../utils/css";
5+
import type { GetSet, Variant } from "../../utils/types";
6+
import Icon from "../primitives/Icon";
7+
import InfiniteScroll, { type InfiniteScrollRef } from "../primitives/InfiniteScroll";
8+
import Input from "../primitives/Input";
9+
import Text from "../primitives/Text";
10+
import Group from "./Group";
11+
import Modal from "./Modal";
12+
13+
const selectStyles = tv({
14+
base: [
15+
"rounded-sm ease flex items-center focus-visible:outline-main-12 !leading-none justify-between text-nowrap font-text font-normal",
16+
],
17+
slots: {
18+
dropdown: "outline-0 z-50 origin-top animate-drop animate-stretch mt-sm min-w-[var(--popover-anchor-width)]",
19+
item: "rounded-sm flex justify-between items-center gap-md cursor-pointer select-none p-sm outline-offset-0 outline-0 text-nowrap focus-visible:outline-main-12",
20+
icon: "flex items-center",
21+
value: "flex gap-sm items-center",
22+
check: "",
23+
prefixLabel: "",
24+
},
25+
variants: {
26+
look: {
27+
soft: {
28+
base: "bg-main-0 text-main-11 border-1 border-main-0 hover:text-main-12 active:border-main-11",
29+
icon: "border-main-0",
30+
item: "font-text hover:bg-main-5 data-[active-item]:bg-main-5 active:bg-main-4 text-main-12",
31+
},
32+
base: {
33+
base: "bg-main-0 text-main-11 border-1 border-main-9 hover:text-main-12 active:border-main-11",
34+
icon: "border-main-0",
35+
item: "font-text hover:bg-main-5 data-[active-item]:bg-main-5 active:bg-main-4 text-main-12",
36+
},
37+
bold: {
38+
base: "bg-main-1 text-main-11 border-1 border-main-0 hover:text-main-12 active:border-main-8",
39+
icon: "",
40+
item: "font-text hover:bg-main-5 data-[active-item]:bg-main-5 active:bg-main-4 text-main-12",
41+
check: "text-accent-10",
42+
},
43+
tint: {
44+
base: "bg-main-5 text-main-11 border-1 border-main-0 hover:text-main-12 active:border-main-8",
45+
icon: "",
46+
item: "font-text hover:bg-main-3 data-[active-item]:bg-main-6 active:bg-main-5 text-main-12",
47+
},
48+
hype: {
49+
base: "bg-main-8 text-main-12 border-1 border-main-0 hover:bg-main-10 active:border-stroke-11",
50+
icon: "",
51+
item: "font-text hover:bg-accent-3 data-[active-item]:bg-accent-3 active:bg-accent-4 text-main-12",
52+
},
53+
},
54+
size: {
55+
xs: {
56+
base: "text-xs",
57+
value: "px-sm*2 py-xs*2",
58+
icon: "text-sm",
59+
item: "px-md text-xs",
60+
prefixLabel: "text-xs",
61+
},
62+
sm: {
63+
base: "text-sm",
64+
value: "px-md py-sm",
65+
icon: "text-base",
66+
item: "px-md text-sm",
67+
prefixLabel: "text-sm",
68+
},
69+
md: {
70+
base: "text-md",
71+
value: "px-md text-md py-md",
72+
icon: "text-lg",
73+
item: "px-md text-md",
74+
prefixLabel: "text-sm",
75+
},
76+
lg: {
77+
base: "text-lg",
78+
value: "px-xl/2 py-lg",
79+
icon: "text-xl",
80+
item: "px-lg text-lg",
81+
prefixLabel: "text-base",
82+
},
83+
xl: {
84+
base: "text-xl",
85+
value: "px-sm*2 py-lg",
86+
icon: "text-xl",
87+
item: "px-xl text-xl",
88+
prefixLabel: "text-lg",
89+
},
90+
},
91+
},
92+
defaultVariants: {
93+
look: "base",
94+
size: "md",
95+
},
96+
compoundVariants: [
97+
{
98+
size: "xs",
99+
look: "soft",
100+
class: { icon: "!pl-0", value: "!pr-0" },
101+
},
102+
{
103+
size: "sm",
104+
look: "soft",
105+
class: { icon: "!pl-0", value: "!pr-0" },
106+
},
107+
{
108+
size: "md",
109+
look: "soft",
110+
class: { icon: "!pl-0", value: "!pr-sm/2" },
111+
},
112+
{
113+
size: "lg",
114+
look: "soft",
115+
class: { icon: "!pl-0", value: "!pr-md/2" },
116+
},
117+
{
118+
size: "xl",
119+
look: "soft",
120+
class: { icon: "!pl-0", value: "!pr-lg/2" },
121+
},
122+
],
123+
});
124+
125+
type SelectProps = {
126+
size?: Variant<typeof selectStyles, "size">;
127+
look?: Variant<typeof selectStyles, "look">;
128+
options: { [key in string | number | symbol]: React.ReactNode };
129+
defaultValue?: string;
130+
placeholder?: string;
131+
className?: string;
132+
loading?: boolean;
133+
onNext?: (release: () => void) => Promise<void> | void;
134+
onSearch?: (search: string, release?: () => void) => Promise<void>;
135+
state: GetSet<string | undefined>;
136+
prefix?: React.ReactNode;
137+
suffix?: React.ReactNode;
138+
error?: ReactNode;
139+
};
140+
141+
export default function PaginatedSelect({
142+
options: optionMap,
143+
placeholder = "Select...",
144+
look,
145+
size,
146+
className,
147+
state,
148+
loading,
149+
onNext,
150+
onSearch: onSearchProps,
151+
prefix,
152+
suffix,
153+
error,
154+
}: SelectProps) {
155+
const { base, value: valueStyle } = selectStyles({
156+
look: look ?? "base",
157+
size: size ?? "md",
158+
});
159+
160+
const [search, setSearch] = useState<string>("");
161+
const debouncedSearch = useDebounce(search, 500);
162+
const [selectedValue, setSelectedValue] = state;
163+
164+
const handleSelect = useCallback(
165+
(key: string) => {
166+
setIsModalOpen(false);
167+
setSelectedValue(key);
168+
},
169+
[setSelectedValue],
170+
);
171+
172+
const isSelected = useCallback(
173+
(key: string) => {
174+
return selectedValue === key;
175+
},
176+
[selectedValue],
177+
);
178+
179+
const onSearch = useCallback((search: string) => {
180+
setSearch(search);
181+
}, []);
182+
183+
useEffect(() => {
184+
onSearchProps?.(debouncedSearch);
185+
}, [debouncedSearch, onSearchProps]);
186+
187+
const selectedValueDisplay = useMemo(() => {
188+
if (!selectedValue) return;
189+
return <Group>{optionMap[selectedValue]}</Group>;
190+
}, [selectedValue, optionMap]);
191+
192+
const [isModalOpen, setIsModalOpen] = useState(false);
193+
194+
const renderOptions = useMemo(() => {
195+
const options = Object.entries(optionMap);
196+
if (!options.length) {
197+
const baseText = !!debouncedSearch ? "No result found for " : "No result found ";
198+
return (
199+
<Text look="soft" className="block text-center mt-lg">
200+
{baseText}
201+
<Text look="bold" bold>
202+
{debouncedSearch}
203+
</Text>
204+
</Text>
205+
);
206+
}
207+
return options.map(([key, node]) => (
208+
<Group
209+
key={key}
210+
onClick={() => handleSelect(key)}
211+
className={mergeClass(
212+
"cursor-pointer justify-start w-full py-lg px-xl hover:bg-main-5",
213+
isSelected(key) && "bg-main-3",
214+
)}
215+
size="md">
216+
{node}
217+
</Group>
218+
));
219+
}, [optionMap, debouncedSearch, handleSelect, isSelected]);
220+
221+
const scrollRef = useRef<InfiniteScrollRef>(null);
222+
223+
// biome-ignore lint/correctness/useExhaustiveDependencies: We release the infinite scroll when new debounced search is made
224+
useEffect(() => {
225+
scrollRef.current?.release();
226+
}, [debouncedSearch]);
227+
228+
const toggleModal = useCallback(() => setIsModalOpenWrapper(!isModalOpen), [isModalOpen]);
229+
230+
const setIsModalOpenWrapper = useCallback((bool: boolean) => {
231+
setSearch("");
232+
setIsModalOpen(bool);
233+
}, []);
234+
235+
const renderSuffix = useMemo(() => {
236+
if (selectedValueDisplay) return null;
237+
if (loading) return <Icon className="animate-spin" remix="RiLoader4Fill" />;
238+
return suffix;
239+
}, [suffix, loading, selectedValueDisplay]);
240+
return (
241+
<>
242+
<Modal
243+
state={[isModalOpen, setIsModalOpenWrapper]}
244+
title={<Text look="bold">Select a token</Text>}
245+
modal={
246+
<Group className={mergeClass(" overflow-y-hidden")}>
247+
<Input
248+
type={"string"}
249+
state={[search, onSearch]}
250+
placeholder={"Search a token"}
251+
className="w-full h-[fit-content]"
252+
size="lg"
253+
prefix={<Icon remix="RiSearch2Line" />}
254+
/>
255+
<InfiniteScroll onNext={onNext} ref={scrollRef}>
256+
<div className="overflow-y-auto w-full h-[65vh]">{renderOptions}</div>
257+
</InfiniteScroll>
258+
</Group>
259+
}
260+
/>
261+
<Group className={mergeClass("w-full", className)}>
262+
<Group className={mergeClass(base(), "w-full h-[58px] justify-between")} onClick={toggleModal}>
263+
<Group className={valueStyle()}>
264+
{!selectedValueDisplay && <Group>{prefix}</Group>}
265+
<Text>{selectedValueDisplay ?? placeholder}</Text>
266+
</Group>
267+
<Group className="pr-md">{renderSuffix}</Group>
268+
</Group>
269+
{error}
270+
</Group>
271+
</>
272+
);
273+
}

0 commit comments

Comments
 (0)