From 096473e785f6541bbfc3bf09e3ec327c4de57764 Mon Sep 17 00:00:00 2001 From: Hugo Lextrait Date: Fri, 4 Apr 2025 19:14:35 +0200 Subject: [PATCH 01/14] feat: add option to reset TransactionButton state on success --- src/components/dapp/TransactionButton.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/dapp/TransactionButton.tsx b/src/components/dapp/TransactionButton.tsx index 5147e21b1..2270a5a08 100644 --- a/src/components/dapp/TransactionButton.tsx +++ b/src/components/dapp/TransactionButton.tsx @@ -20,6 +20,7 @@ export type TransactionButtonProps = ButtonProps & { onSuccess?: (hash: string) => void; onError?: (hash: string) => void; onClick?: () => void; + shouldResetOnSuccess?: boolean; }; export default function TransactionButton({ @@ -32,6 +33,7 @@ export default function TransactionButton({ enableSponsorCheckbox, iconProps, onError, + shouldResetOnSuccess, ...props }: TransactionButtonProps) { const [status, setStatus] = useState<"idle" | "pending" | "success">("idle"); @@ -43,6 +45,7 @@ export default function TransactionButton({ sponsorTransactions, setSponsorTransactions, } = useWalletContext(); + const execute = useCallback(async () => { if (!tx || !user || !client) return; @@ -129,6 +132,7 @@ export default function TransactionButton({ state: "good", loading: false, }); + if (!!shouldResetOnSuccess) setStatus("idle"); } } catch (_error) { setStatus("idle"); @@ -140,7 +144,7 @@ export default function TransactionButton({ loading: false, }); } - }, [tx, client, user, sendTransaction, onExecute, onSuccess, onError, name, onClick]); + }, [tx, client, user, shouldResetOnSuccess, sendTransaction, onExecute, onSuccess, onError, name, onClick]); //TODO: remove hardcoded chainId check in favor of more integrated and generic implem if (enableSponsorCheckbox && chainId === 324) @@ -159,6 +163,8 @@ export default function TransactionButton({ } ); + + console.log({ status }); return ( + +// {isOpen && ( +// +//
+// +// {Object.entries(options).map(([key, node]) => ( +// handleSelect(key as T)} +// className={mergeClass( +// item(), +// "cursor-pointer justify-start w-full", +// isSelected(key as T) && "bg-main-3", +// )} +// size="md"> +// {node} +// +// ))} +// +//
+//
+// )} +// + +// ); +// return ( +//
+// + +// {isOpen && ( +// +//
+// +// {Object.entries(options).map(([key, node]) => ( +// handleSelect(key as T)} +// className={mergeClass( +// item(), +// "cursor-pointer justify-start w-full", +// isSelected(key as T) && "bg-main-3", +// )} +// size="md"> +// {node} +// +// ))} +// +//
+//
+// )} +//
+// ); diff --git a/src/components/extenders/Select.tsx b/src/components/extenders/Select.tsx index f1c1690e2..5f788bb81 100644 --- a/src/components/extenders/Select.tsx +++ b/src/components/extenders/Select.tsx @@ -1,7 +1,7 @@ import * as Ariakit from "@ariakit/react"; import type * as RadixSelect from "@radix-ui/react-select"; import { matchSorter } from "match-sorter"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; import { tv } from "tailwind-variants"; import { mergeClass } from "../.."; import type { Component, GetSet, Variant } from "../../utils/types"; @@ -140,7 +140,6 @@ export type SelectProps = Component<{ indexOptions?: { [key: string | number | symbol]: number }; onOpen?: () => void; error?: ReactNode; - allowCustomValue?: boolean; }> & RadixSelect.SelectProps; @@ -169,7 +168,6 @@ export default function Select< defaultValue, error, onChange, - allowCustomValue = false, ..._props }: SelectProps & { multiple?: Multiple }) { const [internal, setInternal] = useState(); @@ -199,7 +197,7 @@ export default function Select< [setter, onChange], ); - const [searchInput, setSearchInput] = useState(); + const [searchInput, setSearch] = useState(); const matches = useMemo(() => { if (!search || !searchInput || searchInput === "") @@ -231,16 +229,13 @@ export default function Select< }, [options, searchOptions, indexOptions, searchInput, search]); const label = useMemo(() => { - if (value && (typeof value === "number" || typeof value === "string" || typeof value === "symbol")) { - if (options?.[value]) return options[value]; - if (displayOptions?.[value]) return displayOptions[value]; - - // If allowCustomValue, just show the raw value - if (allowCustomValue) { - return {String(value)}; - } - } - if (Array.isArray(value) && value.length > 0) + if ( + value && + (typeof value === "number" || typeof value === "string" || typeof value === "symbol") && + options?.[value] + ) + return options?.[value]; + if (typeof value === "object" && value?.length > 0) return ( <> ); - }, [options, displayOptions, value, placeholder, prefixLabel, placeholderIcon, allowCustomValue]); - - const handleInputValueChange = (inputValue: string) => { - setSearchInput(inputValue); - if (allowCustomValue && inputValue && !options?.[inputValue as T]) { - // Custom value entered, update form state - setValue(inputValue as Value); - } - }; - - useEffect(() => { - if (getter === undefined && internal !== undefined) { - setInternal(undefined); - setSearchInput(undefined); - } - }, [getter, internal]); + }, [options, value, placeholder, prefixLabel, placeholderIcon]); return ( { + setSearch(value); + }} setOpen={o => o && onOpen?.()}> - setValue(v as Value)} value={value as string} defaultValue=""> + setValue(v as Value)} + value={value as string} + defaultValue={multiple ? [] : undefined}>
{label}
@@ -294,7 +279,7 @@ export default function Select<
diff --git a/src/components/primitives/InfiniteScroll.tsx b/src/components/primitives/InfiniteScroll.tsx new file mode 100644 index 000000000..95c1d05fa --- /dev/null +++ b/src/components/primitives/InfiniteScroll.tsx @@ -0,0 +1,105 @@ +import React from "react"; + +/** + * @Documentation + * This component is used to create an infinite scroll effect. + * + * Usage: + * + * No pagination Example + + const onSearch = useCallback((searchParam: string) => productService.search(searchParam).then((products) => setRows(buildRows(products))), []); + + useEffect(() => { + productService.getLastProductSheets().then((products) => setRows(buildRows(products))); + }, []); + * + * + * + * With pagination Example + * + const fetchClients = useCallback( + () => + clientService.get(pagination.current, search.current).then((clients) => { + if (clients.length === 0) return []; + setRows((_rows) => [..._rows, ...buildRows(clients)]); + pagination.current.skip += pagination.current.take; + return clients; + }), + [], + ); + + const onNext = useCallback( + (release: () => void) => { + fetchClients().then((clients) => { + if (!clients.length) return console.warn("No more value to load"); + release(); + }); + }, + [fetchClients], + ); + + const onSearch = useCallback((searchParam: string) => { + pagination.current.skip = 0; + search.current = (searchParam && searchParam.trim()) || null; + setRows([]); + }, []); + * + */ +export type IPagination = { + take: number; + skip: number; +}; + +type IProps = { + offset?: number; + orientation?: "vertical" | "horizontal"; + /** + * @description + * If `onNext` is set to `null`, it indicates that there is no pagination and the infinite scroll effect will not be triggered. + */ + onNext?: ((release: () => void, reset?: () => void) => Promise | void) | null; + children: React.ReactElement; +}; + +export default function InfiniteScroll({ children, onNext, offset = 20, orientation = "vertical" }: IProps) { + const isWaiting = React.useRef(false); + const elementRef = React.useRef(); + + const onChange = React.useCallback(() => { + if (!onNext) return; + const element = elementRef.current; + if (!element || isWaiting.current) return; + const { scrollTop, scrollLeft, clientHeight, clientWidth, scrollHeight, scrollWidth } = element; + let isChange = false; + + if (orientation === "vertical") isChange = scrollTop + clientHeight >= scrollHeight - offset; + if (orientation === "horizontal") isChange = scrollLeft + clientWidth >= scrollWidth - offset; + + if (isChange) { + isWaiting.current = true; + onNext(() => (isWaiting.current = false)); + } + }, [onNext, offset, orientation]); + + React.useEffect(() => onChange(), [onChange]); + + React.useEffect(() => { + const observer = new MutationObserver(onChange); + elementRef.current && observer.observe(elementRef.current, { childList: true, subtree: true }); + window.addEventListener("resize", onChange); + return () => { + observer.disconnect(); + window.removeEventListener("resize", onChange); + }; + }, [onChange]); + + if (!onNext) return children; + + const clonedChild = React.cloneElement(children, { + onScroll: onChange, + ref: elementRef, + }); + + return clonedChild; +} From 8eebe7c8f6a08a1e2404bd4390856be5ff64fa7d Mon Sep 17 00:00:00 2001 From: Hugo Lextrait Date: Mon, 14 Apr 2025 15:15:21 +0200 Subject: [PATCH 06/14] fix: ensure default value in Select component is an empty string for better handling of undefined states --- src/components/extenders/Select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/extenders/Select.tsx b/src/components/extenders/Select.tsx index 5f788bb81..9017d2c67 100644 --- a/src/components/extenders/Select.tsx +++ b/src/components/extenders/Select.tsx @@ -186,7 +186,7 @@ export default function Select< size: size ?? "md", }); - const value = useMemo(() => getter ?? internal, [getter, internal]); + const value = useMemo(() => getter ?? internal ?? "", [getter, internal]); const setValue = useCallback( // biome-ignore lint/suspicious/noExplicitAny: From 3388d23e5938106cbcfa02eed8c5570bf9a4ea71 Mon Sep 17 00:00:00 2001 From: Hugo Lextrait Date: Mon, 14 Apr 2025 18:37:13 +0200 Subject: [PATCH 07/14] feat: implement searchable options in PaginatedSelect component --- src/components/extenders/PaginatedSelect.tsx | 253 +++++++++---------- 1 file changed, 122 insertions(+), 131 deletions(-) diff --git a/src/components/extenders/PaginatedSelect.tsx b/src/components/extenders/PaginatedSelect.tsx index ae699a6f0..44dab15ac 100644 --- a/src/components/extenders/PaginatedSelect.tsx +++ b/src/components/extenders/PaginatedSelect.tsx @@ -1,22 +1,25 @@ -// components/Select.tsx +import * as Popover from "@radix-ui/react-popover"; import { useCallback, useEffect, useRef, useState } from "react"; import { tv } from "tailwind-variants"; -import { useCall } from "wagmi"; +import { useTheme } from "../../context/Theme.context"; import { mergeClass } from "../../utils/css"; -import type { Variant } from "../../utils/types"; -import Button from "../primitives/Button"; +import type { GetSet, Variant } from "../../utils/types"; +import Box from "../primitives/Box"; +import EventBlocker from "../primitives/EventBlocker"; import Icon from "../primitives/Icon"; import InfiniteScroll from "../primitives/InfiniteScroll"; +import Input from "../primitives/Input"; import Text from "../primitives/Text"; -import Dropdown from "./Dropdown"; import Group from "./Group"; +import { blockEvent } from "../../utils/event"; const selectStyles = tv({ base: [ "rounded-sm ease flex items-center focus-visible:outline-main-12 !leading-none justify-between text-nowrap font-text font-normal", ], slots: { - dropdown: "outline-0 z-50 origin-top animate-drop animate-stretch mt-sm min-w-[var(--popover-anchor-width)]", + dropdown: + "outline-0 z-50 origin-top animate-drop animate-stretch mt-sm min-w-[var(--popover-anchor-width)]", 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", icon: "flex items-center", value: "flex gap-sm items-center", @@ -123,67 +126,61 @@ const selectStyles = tv({ ], }); -// type Option = { -// label: string; -// value: T; -// }; type SelectProps = { size?: Variant; look?: Variant; options: { [key in string | number | symbol]: React.ReactNode }; - value?: T | T[]; defaultValue?: T | T[]; placeholder?: string; - onChange?: (value: any) => void; className?: string; loading?: boolean; multiple?: boolean; onNext?: (release: () => void) => Promise | void; + onSearch?: (search: string) => Promise; + state: GetSet; }; export default function PaginatedSelect({ options, - value, defaultValue, placeholder = "Select...", look, size, className, + state, loading, multiple = false, onNext, - onChange, + onSearch, }: SelectProps) { const { base, - dropdown, item, - icon, value: valueStyle, } = selectStyles({ look: look ?? "base", size: size ?? "md", }); - const [isOpen, setIsOpen] = useState(false); - const [internalValue, setInternalValue] = useState(defaultValue); - const selectRef = useRef(null); - - const selectedValue = value !== undefined ? value : internalValue; + const [_isOpenSelect, setIsOpenSelect] = useState(false); + const [internalValue, setInternalValue] = useState( + defaultValue + ); + const selectedValue = state ? state[0] : internalValue; + const setSelectedValue = state ? state[1] : setInternalValue; const handleSelect = (val: T) => { if (multiple) { const selected = Array.isArray(selectedValue) ? selectedValue : []; const alreadySelected = selected.includes(val); - const newValue = alreadySelected ? selected.filter(v => v !== val) : [...selected, val]; - - onChange?.(newValue); - setInternalValue(newValue); + const newValue = alreadySelected + ? selected.filter((v) => v !== val) + : [...selected, val]; + setSelectedValue(newValue); } else { - onChange?.(val); - setInternalValue(val); - setIsOpen(false); + setIsOpenSelect(false); + setSelectedValue(val); } }; @@ -191,7 +188,9 @@ export default function PaginatedSelect({ if (multiple && Array.isArray(selectedValue)) { return selectedValue.length === 0 ? placeholder - : `${selectedValue.length} option${selectedValue.length > 1 ? "s" : ""} selected`; + : `${selectedValue.length} option${ + selectedValue.length > 1 ? "s" : "" + } selected`; } if (!multiple && selectedValue !== undefined) { @@ -203,9 +202,7 @@ export default function PaginatedSelect({ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - if (selectRef.current && !selectRef.current.contains(e.target as Node)) { - setIsOpen(false); - } + setIsOpenSelect(false); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); @@ -218,107 +215,101 @@ export default function PaginatedSelect({ return selectedValue === key; }; - const toggleOpen = useCallback(() => setIsOpen(prev => !prev), [isOpen]); + const [search, setSearch] = useState(""); - return ( - -
- {Object.entries(options).map(([key, node]) => ( - handleSelect(key as T)} - className={mergeClass( - item(), - "cursor-pointer justify-start w-full", - isSelected(key as T) && "bg-main-3", - )} - size="md"> - {node} - - ))} -
- - }> - - {selectedLabel} - - {loading ? : } - - -
+ const onSearchChange = useCallback( + (search: string | undefined) => { + onSearch?.(search); + setSearch(search); + }, + [onSearch] ); -} -//
-// -// {isOpen && ( -// -//
-// -// {Object.entries(options).map(([key, node]) => ( -// handleSelect(key as T)} -// className={mergeClass( -// item(), -// "cursor-pointer justify-start w-full", -// isSelected(key as T) && "bg-main-3", -// )} -// size="md"> -// {node} -// -// ))} -// -//
-//
-// )} -//
-// ); -// return ( -//
-// + /* + */ + const { vars } = useTheme(); + const [internalState, setInternalState] = useState(false); + const hideTimeout = useRef | null>(null); -// {isOpen && ( -// -//
-// -// {Object.entries(options).map(([key, node]) => ( -// handleSelect(key as T)} -// className={mergeClass( -// item(), -// "cursor-pointer justify-start w-full", -// isSelected(key as T) && "bg-main-3", -// )} -// size="md"> -// {node} -// -// ))} -// -//
-//
-// )} -//
-// ); + + useEffect(() => { + if (!hideTimeout.current) return; + clearTimeout(hideTimeout.current); + }, []); + + const toggle = useCallback( + () => + blockEvent(() => { + setInternalState(!internalState); + }), + [internalState, state] + ); + + return ( + + { + return setInternalState(o); + }} + > + + { + + + {selectedLabel ?? placeholder} + + + {loading ? ( + + ) : ( + + )} + + + } + + + + + + { + +
+ + {Object.entries(options).map(([key, node]) => ( + handleSelect(key as T)} + className={mergeClass(item(), + "cursor-pointer justify-start w-full !truncate", + isSelected(key as T) && "bg-main-3" + )} + size="md" + > + {node} + + ))} +
+
+ } +
+
+
+
+
+
+ ); +} \ No newline at end of file From 75a40bc12d54089f108c466d8e501bb725ee6de1 Mon Sep 17 00:00:00 2001 From: Hugo Lextrait Date: Tue, 15 Apr 2025 15:33:18 +0200 Subject: [PATCH 08/14] feat: enhance PaginatedSelect with modal and search functionality; refactor Select and InfiniteScroll components --- src/components/extenders/PaginatedSelect.tsx | 266 ++++++++----------- src/components/extenders/Select.tsx | 7 +- src/components/primitives/InfiniteScroll.tsx | 108 +++++--- src/hooks/useDebounce.ts | 23 ++ 4 files changed, 199 insertions(+), 205 deletions(-) create mode 100644 src/hooks/useDebounce.ts diff --git a/src/components/extenders/PaginatedSelect.tsx b/src/components/extenders/PaginatedSelect.tsx index 44dab15ac..e188387b1 100644 --- a/src/components/extenders/PaginatedSelect.tsx +++ b/src/components/extenders/PaginatedSelect.tsx @@ -1,25 +1,21 @@ -import * as Popover from "@radix-ui/react-popover"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { tv } from "tailwind-variants"; -import { useTheme } from "../../context/Theme.context"; +import useDebounce from "../../hooks/useDebounce"; import { mergeClass } from "../../utils/css"; import type { GetSet, Variant } from "../../utils/types"; -import Box from "../primitives/Box"; -import EventBlocker from "../primitives/EventBlocker"; import Icon from "../primitives/Icon"; -import InfiniteScroll from "../primitives/InfiniteScroll"; +import InfiniteScroll, { type InfiniteScrollRef } from "../primitives/InfiniteScroll"; import Input from "../primitives/Input"; import Text from "../primitives/Text"; import Group from "./Group"; -import { blockEvent } from "../../utils/event"; +import Modal from "./Modal"; const selectStyles = tv({ base: [ "rounded-sm ease flex items-center focus-visible:outline-main-12 !leading-none justify-between text-nowrap font-text font-normal", ], slots: { - dropdown: - "outline-0 z-50 origin-top animate-drop animate-stretch mt-sm min-w-[var(--popover-anchor-width)]", + dropdown: "outline-0 z-50 origin-top animate-drop animate-stretch mt-sm min-w-[var(--popover-anchor-width)]", 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", icon: "flex items-center", value: "flex gap-sm items-center", @@ -126,190 +122,136 @@ const selectStyles = tv({ ], }); - -type SelectProps = { +type SelectProps = { size?: Variant; look?: Variant; options: { [key in string | number | symbol]: React.ReactNode }; - defaultValue?: T | T[]; + defaultValue?: string; placeholder?: string; className?: string; loading?: boolean; - multiple?: boolean; onNext?: (release: () => void) => Promise | void; - onSearch?: (search: string) => Promise; - state: GetSet; + onSearch?: (search: string, release?: () => void) => Promise; + state: GetSet; + prefix?: React.ReactNode; + error?: ReactNode; }; -export default function PaginatedSelect({ - options, - defaultValue, +export default function PaginatedSelect({ + options: optionMap, placeholder = "Select...", look, size, className, state, loading, - multiple = false, onNext, - onSearch, -}: SelectProps) { - const { - base, - item, - value: valueStyle, - } = selectStyles({ + onSearch: onSearchProps, + prefix, + error, +}: SelectProps) { + const { base, value: valueStyle } = selectStyles({ look: look ?? "base", size: size ?? "md", }); - const [_isOpenSelect, setIsOpenSelect] = useState(false); - const [internalValue, setInternalValue] = useState( - defaultValue - ); - const selectedValue = state ? state[0] : internalValue; - const setSelectedValue = state ? state[1] : setInternalValue; - - const handleSelect = (val: T) => { - if (multiple) { - const selected = Array.isArray(selectedValue) ? selectedValue : []; - const alreadySelected = selected.includes(val); - const newValue = alreadySelected - ? selected.filter((v) => v !== val) - : [...selected, val]; - setSelectedValue(newValue); - } else { - setIsOpenSelect(false); - setSelectedValue(val); - } - }; - - const selectedLabel = (() => { - if (multiple && Array.isArray(selectedValue)) { - return selectedValue.length === 0 - ? placeholder - : `${selectedValue.length} option${ - selectedValue.length > 1 ? "s" : "" - } selected`; - } + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 500); + const [selectedValue, setSelectedValue] = state; - if (!multiple && selectedValue !== undefined) { - return options[selectedValue as keyof typeof options]; - } + const handleSelect = useCallback( + (key: string) => { + setIsModalOpen(false); + setSelectedValue(key); + }, + [setSelectedValue], + ); - return placeholder; - })(); + const isSelected = useCallback( + (key: string) => { + return selectedValue === key; + }, + [selectedValue], + ); - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - setIsOpenSelect(false); - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); + const onSearch = useCallback((search: string) => { + setSearch(search); }, []); - const isSelected = (key: T) => { - if (multiple && Array.isArray(selectedValue)) { - return selectedValue.includes(key); - } - return selectedValue === key; - }; + useEffect(() => { + onSearchProps?.(debouncedSearch); + }, [debouncedSearch, onSearchProps]); - const [search, setSearch] = useState(""); + const selectedValueDisplay = useMemo(() => { + if (!selectedValue) return; + return {optionMap[selectedValue]}; + }, [selectedValue, optionMap]); - const onSearchChange = useCallback( - (search: string | undefined) => { - onSearch?.(search); - setSearch(search); - }, - [onSearch] - ); + const [isModalOpen, setIsModalOpen] = useState(false); + const renderOptions = useMemo(() => { + const options = Object.entries(optionMap); + if (!options.length) { + const baseText = !!debouncedSearch ? "No result found for " : "No result found "; + return ( + + {baseText} + + {debouncedSearch} + + + ); + } + return options.map(([key, node]) => ( + handleSelect(key)} + className={mergeClass( + "cursor-pointer justify-start w-full py-lg px-xl hover:bg-main-5", + isSelected(key) && "bg-main-3", + )} + size="md"> + {node} + + )); + }, [optionMap, debouncedSearch, handleSelect, isSelected]); - /* - */ - const { vars } = useTheme(); - const [internalState, setInternalState] = useState(false); - const hideTimeout = useRef | null>(null); - + const scrollRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: We release the infinite scroll when new debounced search is made useEffect(() => { - if (!hideTimeout.current) return; - clearTimeout(hideTimeout.current); - }, []); - - const toggle = useCallback( - () => - blockEvent(() => { - setInternalState(!internalState); - }), - [internalState, state] - ); + scrollRef.current?.release(); + }, [debouncedSearch]); return ( - - { - return setInternalState(o); - }} - > - - { - - - {selectedLabel ?? placeholder} - - - {loading ? ( - - ) : ( - - )} - - - } - - - - - - { - -
- - {Object.entries(options).map(([key, node]) => ( - handleSelect(key as T)} - className={mergeClass(item(), - "cursor-pointer justify-start w-full !truncate", - isSelected(key as T) && "bg-main-3" - )} - size="md" - > - {node} - - ))} -
-
- } -
-
-
-
-
-
+ Select a token} + modal={ + + } + /> + +
{renderOptions}
+
+
+ }> + + + + {!selectedValueDisplay && {prefix}} + {selectedValueDisplay ?? placeholder} + + {loading && } + + {error} + +
); -} \ No newline at end of file +} diff --git a/src/components/extenders/Select.tsx b/src/components/extenders/Select.tsx index 9017d2c67..51f5a17d6 100644 --- a/src/components/extenders/Select.tsx +++ b/src/components/extenders/Select.tsx @@ -186,7 +186,12 @@ export default function Select< size: size ?? "md", }); - const value = useMemo(() => getter ?? internal ?? "", [getter, internal]); + const value = useMemo(() => { + if (!!getter) return getter; + if (!!internal) return internal; + if (!!multiple) return []; + return ""; + }, [getter, internal, multiple]); const setValue = useCallback( // biome-ignore lint/suspicious/noExplicitAny: diff --git a/src/components/primitives/InfiniteScroll.tsx b/src/components/primitives/InfiniteScroll.tsx index 95c1d05fa..a26e7a4b1 100644 --- a/src/components/primitives/InfiniteScroll.tsx +++ b/src/components/primitives/InfiniteScroll.tsx @@ -1,5 +1,3 @@ -import React from "react"; - /** * @Documentation * This component is used to create an infinite scroll effect. @@ -46,60 +44,86 @@ import React from "react"; }, []); * */ -export type IPagination = { - take: number; - skip: number; -}; +import React from "react"; -type IProps = { +export interface InfiniteScrollRef { + release: () => void; +} + +export type IProps = { offset?: number; orientation?: "vertical" | "horizontal"; /** - * @description - * If `onNext` is set to `null`, it indicates that there is no pagination and the infinite scroll effect will not be triggered. + * @description If `onNext` is set to `null`, it indicates that there is no pagination and the infinite scroll effect will not be triggered. */ onNext?: ((release: () => void, reset?: () => void) => Promise | void) | null; children: React.ReactElement; }; -export default function InfiniteScroll({ children, onNext, offset = 20, orientation = "vertical" }: IProps) { - const isWaiting = React.useRef(false); - const elementRef = React.useRef(); +const InfiniteScroll = React.forwardRef( + ({ children, onNext, offset = 20, orientation = "vertical" }, ref) => { + const isWaiting = React.useRef(false); + const elementRef = React.useRef(); - const onChange = React.useCallback(() => { - if (!onNext) return; - const element = elementRef.current; - if (!element || isWaiting.current) return; - const { scrollTop, scrollLeft, clientHeight, clientWidth, scrollHeight, scrollWidth } = element; - let isChange = false; + // Expose a release method to the parent via the ref + React.useImperativeHandle( + ref, + () => ({ + release: () => { + isWaiting.current = false; + }, + }), + [], + ); - if (orientation === "vertical") isChange = scrollTop + clientHeight >= scrollHeight - offset; - if (orientation === "horizontal") isChange = scrollLeft + clientWidth >= scrollWidth - offset; + const onChange = React.useCallback(() => { + if (!onNext) return; + const element = elementRef.current; + if (!element || isWaiting.current) return; + const { scrollTop, scrollLeft, clientHeight, clientWidth, scrollHeight, scrollWidth } = element; + let isChange = false; - if (isChange) { - isWaiting.current = true; - onNext(() => (isWaiting.current = false)); - } - }, [onNext, offset, orientation]); + if (orientation === "vertical") { + isChange = scrollTop + clientHeight >= scrollHeight - offset; + } + if (orientation === "horizontal") { + isChange = scrollLeft + clientWidth >= scrollWidth - offset; + } - React.useEffect(() => onChange(), [onChange]); + if (isChange) { + isWaiting.current = true; + // We pass the release function to onNext. The parent can complete its async operation and then call release manually. + onNext(() => { + isWaiting.current = false; + }); + } + }, [onNext, offset, orientation]); - React.useEffect(() => { - const observer = new MutationObserver(onChange); - elementRef.current && observer.observe(elementRef.current, { childList: true, subtree: true }); - window.addEventListener("resize", onChange); - return () => { - observer.disconnect(); - window.removeEventListener("resize", onChange); - }; - }, [onChange]); + React.useEffect(() => { + onChange(); + }, [onChange]); - if (!onNext) return children; + React.useEffect(() => { + const observer = new MutationObserver(onChange); + if (elementRef.current) { + observer.observe(elementRef.current, { childList: true, subtree: true }); + } + window.addEventListener("resize", onChange); + return () => { + observer.disconnect(); + window.removeEventListener("resize", onChange); + }; + }, [onChange]); - const clonedChild = React.cloneElement(children, { - onScroll: onChange, - ref: elementRef, - }); + if (!onNext) return children; - return clonedChild; -} + const clonedChild = React.cloneElement(children, { + onScroll: onChange, + ref: elementRef, + }); + + return clonedChild; + }, +); + +export default InfiniteScroll; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 000000000..42a98ea8a --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +/** + * Hook to debounce a value + * @param value The value to debounce + * @param delay The delay in milliseconds + * @returns The debounced value + */ +export default function useDebounce(value: string | undefined, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} From e95669b6319072fe49622c830614a975e2cd354d Mon Sep 17 00:00:00 2001 From: Hugo Lextrait Date: Tue, 15 Apr 2025 16:26:20 +0200 Subject: [PATCH 09/14] feat: refactor PaginatedSelect, Select, and Input components for improved structure and error handling --- src/components/extenders/PaginatedSelect.tsx | 43 ++--- src/components/extenders/Select.tsx | 166 ++++++++++--------- src/components/primitives/Input.tsx | 13 +- 3 files changed, 113 insertions(+), 109 deletions(-) diff --git a/src/components/extenders/PaginatedSelect.tsx b/src/components/extenders/PaginatedSelect.tsx index e188387b1..97b53cebb 100644 --- a/src/components/extenders/PaginatedSelect.tsx +++ b/src/components/extenders/PaginatedSelect.tsx @@ -223,27 +223,30 @@ export default function PaginatedSelect({ scrollRef.current?.release(); }, [debouncedSearch]); + const toggleModal = useCallback(() => setIsModalOpen(prev => !prev), []); return ( - Select a token} - modal={ - - } - /> - -
{renderOptions}
-
-
- }> + <> + Select a token} + modal={ + + } + /> + +
{renderOptions}
+
+
+ } + /> - + {!selectedValueDisplay && {prefix}} {selectedValueDisplay ?? placeholder} @@ -252,6 +255,6 @@ export default function PaginatedSelect({ {error} -
+ ); } diff --git a/src/components/extenders/Select.tsx b/src/components/extenders/Select.tsx index 51f5a17d6..3e284846c 100644 --- a/src/components/extenders/Select.tsx +++ b/src/components/extenders/Select.tsx @@ -268,89 +268,91 @@ export default function Select< setSearch(value); }} setOpen={o => o && onOpen?.()}> - setValue(v as Value)} - value={value as string} - defaultValue={multiple ? [] : undefined}> - -
{label}
-
- {loading ? : } -
-
- - - {search && ( -
- -
- )} - - - {allOption && !searchInput && ( - - // biome-ignore lint/suspicious/noExplicitAny: template makes this typing difficult even tough it works - setValue((!!multiple ? [] : undefined) as any as Value) - } - render={ - - {allOption} - , - , - ]} - /> - } + + setValue(v as Value)} + value={value as string} + defaultValue={multiple ? [] : undefined}> + +
{label}
+
+ {loading ? : } +
+
+ + + {search && ( +
+ - )} - {matches?.map(_value => ( - - {displayOptions?.[_value as string] ?? options?.[_value as string]} - , - , - ]} - /> - } - /> - ))} - - - - - - {error} +
+ )} + + + {allOption && !searchInput && ( + + // biome-ignore lint/suspicious/noExplicitAny: template makes this typing difficult even tough it works + setValue((!!multiple ? [] : undefined) as any as Value) + } + render={ + + {allOption} +
, + , + ]} + /> + } + /> + )} + {matches?.map(_value => ( + + {displayOptions?.[_value as string] ?? options?.[_value as string]} + , + , + ]} + /> + } + /> + ))} +
+
+
+
+
+ {error} + ); } diff --git a/src/components/primitives/Input.tsx b/src/components/primitives/Input.tsx index 83997d39e..aa9ba1ab1 100644 --- a/src/components/primitives/Input.tsx +++ b/src/components/primitives/Input.tsx @@ -64,10 +64,9 @@ export type InputProps = Component< function Input({ look, size, state, inputClassName, className, ...props }: InputProps) { const { header, footer, prefix, suffix, label, hint, error, ...rest } = props; - if (extensions.some(extension => !!props?.[extension])) return ( - <> + - {error} - + {error} + ); return ( - <> + state?.[1]?.(e?.target?.value)} {...rest} /> - {error} - + {error}{" "} + ); } From 18e0696d61d0e772d0afd0e850f91be812b2020d Mon Sep 17 00:00:00 2001 From: Hugo Lextrait Date: Tue, 15 Apr 2025 18:21:24 +0200 Subject: [PATCH 10/14] fix: update modal state handling in PaginatedSelect and improve Input component class merging --- src/components/extenders/PaginatedSelect.tsx | 14 ++++++++++---- src/components/primitives/Input.tsx | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/extenders/PaginatedSelect.tsx b/src/components/extenders/PaginatedSelect.tsx index 97b53cebb..6d88891a1 100644 --- a/src/components/extenders/PaginatedSelect.tsx +++ b/src/components/extenders/PaginatedSelect.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { tv } from "tailwind-variants"; import useDebounce from "../../hooks/useDebounce"; import { mergeClass } from "../../utils/css"; @@ -179,6 +179,7 @@ export default function PaginatedSelect({ }, []); useEffect(() => { + console.log("RELEASED"); onSearchProps?.(debouncedSearch); }, [debouncedSearch, onSearchProps]); @@ -223,14 +224,19 @@ export default function PaginatedSelect({ scrollRef.current?.release(); }, [debouncedSearch]); - const toggleModal = useCallback(() => setIsModalOpen(prev => !prev), []); + const toggleModal = useCallback(() => setIsModalOpenWrapper(!isModalOpen), [isModalOpen]); + + const setIsModalOpenWrapper = useCallback((bool: boolean) => { + setSearch(""); + setIsModalOpen(bool); + }, []); return ( <> Select a token} modal={ - + !!props?.[extension])) return ( - +