Skip to content

Commit c723aa9

Browse files
committed
Improved Multi Network Selector component (#4953)
Fixes: DASH-298 Its _blazingly fast_ ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/yNOf1svJ8o3zjO7zQouZ/bf448bb9-716e-4c47-8930-0e75e5d32905.png) <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on refactoring the `NetworkDropdown` component into a new `MultiNetworkSelector` component, enhancing the multi-selection capabilities for network options while improving the overall code structure and functionality. ### Detailed summary - Renamed `NetworkDropdown` to `MultiNetworkSelector` for multi-selection functionality. - Updated `NetworksFieldset` and `AccountAbstractionSettingsPage` to utilize `MultiNetworkSelector`. - Enhanced `MultiSelect` component with a `searchPlaceholder` prop and improved search functionality. - Added `overrideSearchFn` and `renderOption` props to `MultiSelect` for customizable option rendering. - Introduced `data-scrollable` attribute in `ScrollShadow` for better scrolling behavior. - Improved styling and layout adjustments in various components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 33c2f56 commit c723aa9

File tree

5 files changed

+171
-65
lines changed

5 files changed

+171
-65
lines changed

apps/dashboard/src/@/components/blocks/multi-select.tsx

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-restricted-syntax */
12
"use client";
23

34
import { Button } from "@/components/ui/button";
@@ -9,7 +10,14 @@ import {
910
import { Separator } from "@/components/ui/separator";
1011
import { cn } from "@/lib/utils";
1112
import { CheckIcon, ChevronDown, SearchIcon, XIcon } from "lucide-react";
12-
import * as React from "react";
13+
import {
14+
forwardRef,
15+
useCallback,
16+
useEffect,
17+
useMemo,
18+
useRef,
19+
useState,
20+
} from "react";
1321
import { useShowMore } from "../../lib/useShowMore";
1422
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow";
1523
import { Input } from "../ui/input";
@@ -24,6 +32,7 @@ interface MultiSelectProps
2432
selectedValues: string[];
2533
onSelectedValuesChange: (value: string[]) => void;
2634
placeholder: string;
35+
searchPlaceholder?: string;
2736

2837
/**
2938
* Maximum number of items to display. Extra selected items will be summarized.
@@ -32,12 +41,16 @@ interface MultiSelectProps
3241
maxCount?: number;
3342

3443
className?: string;
44+
45+
overrideSearchFn?: (
46+
option: { value: string; label: string },
47+
searchTerm: string,
48+
) => boolean;
49+
50+
renderOption?: (option: { value: string; label: string }) => React.ReactNode;
3551
}
3652

37-
export const MultiSelect = React.forwardRef<
38-
HTMLButtonElement,
39-
MultiSelectProps
40-
>(
53+
export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
4154
(
4255
{
4356
options,
@@ -50,10 +63,10 @@ export const MultiSelect = React.forwardRef<
5063
},
5164
ref,
5265
) => {
53-
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
54-
const [searchValue, setSearchValue] = React.useState("");
66+
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
67+
const [searchValue, setSearchValue] = useState("");
5568

56-
const handleInputKeyDown = React.useCallback(
69+
const handleInputKeyDown = useCallback(
5770
(event: React.KeyboardEvent<HTMLInputElement>) => {
5871
if (event.key === "Enter") {
5972
setIsPopoverOpen(true);
@@ -66,7 +79,7 @@ export const MultiSelect = React.forwardRef<
6679
[selectedValues, onSelectedValuesChange],
6780
);
6881

69-
const toggleOption = React.useCallback(
82+
const toggleOption = useCallback(
7083
(option: string) => {
7184
const newSelectedValues = selectedValues.includes(option)
7285
? selectedValues.filter((value) => value !== option)
@@ -76,30 +89,62 @@ export const MultiSelect = React.forwardRef<
7689
[selectedValues, onSelectedValuesChange],
7790
);
7891

79-
const handleClear = React.useCallback(() => {
92+
const handleClear = useCallback(() => {
8093
onSelectedValuesChange([]);
8194
}, [onSelectedValuesChange]);
8295

8396
const handleTogglePopover = () => {
8497
setIsPopoverOpen((prev) => !prev);
8598
};
8699

87-
const clearExtraOptions = React.useCallback(() => {
100+
const clearExtraOptions = useCallback(() => {
88101
const newSelectedValues = selectedValues.slice(0, maxCount);
89102
onSelectedValuesChange(newSelectedValues);
90103
}, [selectedValues, onSelectedValuesChange, maxCount]);
91104

92105
// show 50 initially and then 20 more when reaching the end
93106
const { itemsToShow, lastItemRef } = useShowMore<HTMLButtonElement>(50, 20);
94107

95-
const optionsToShow = React.useMemo(() => {
108+
const { overrideSearchFn } = props;
109+
110+
const optionsToShow = useMemo(() => {
111+
const filteredOptions: {
112+
label: string;
113+
value: string;
114+
}[] = [];
115+
96116
const searchValLowercase = searchValue.toLowerCase();
97-
const filteredOptions = options.filter((option) => {
98-
return option.label.toLowerCase().includes(searchValLowercase);
99-
});
100117

101-
return filteredOptions.slice(0, itemsToShow);
102-
}, [options, searchValue, itemsToShow]);
118+
for (let i = 0; i <= options.length - 1; i++) {
119+
if (filteredOptions.length >= itemsToShow) {
120+
break;
121+
}
122+
if (overrideSearchFn) {
123+
if (overrideSearchFn(options[i], searchValLowercase)) {
124+
filteredOptions.push(options[i]);
125+
}
126+
} else {
127+
if (options[i].label.toLowerCase().includes(searchValLowercase)) {
128+
filteredOptions.push(options[i]);
129+
}
130+
}
131+
}
132+
133+
return filteredOptions;
134+
}, [options, searchValue, itemsToShow, overrideSearchFn]);
135+
136+
// scroll to top when options change
137+
const popoverElRef = useRef<HTMLDivElement>(null);
138+
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
139+
useEffect(() => {
140+
const scrollContainer =
141+
popoverElRef.current?.querySelector("[data-scrollable]");
142+
if (scrollContainer) {
143+
scrollContainer.scrollTo({
144+
top: 0,
145+
});
146+
}
147+
}, [searchValue]);
103148

104149
return (
105150
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
@@ -109,7 +154,7 @@ export const MultiSelect = React.forwardRef<
109154
{...props}
110155
onClick={handleTogglePopover}
111156
className={cn(
112-
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border bg-inherit p-3 hover:bg-inherit",
157+
"flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit",
113158
className,
114159
)}
115160
>
@@ -119,10 +164,14 @@ export const MultiSelect = React.forwardRef<
119164
<div className="flex flex-wrap items-center gap-1.5">
120165
{selectedValues.slice(0, maxCount).map((value) => {
121166
const option = options.find((o) => o.value === value);
167+
if (!option) {
168+
return null;
169+
}
170+
122171
return (
123172
<ClosableBadge
124173
key={value}
125-
label={option?.label || ""}
174+
label={option.label}
126175
onClose={() => toggleOption(value)}
127176
/>
128177
);
@@ -170,20 +219,21 @@ export const MultiSelect = React.forwardRef<
170219
</Button>
171220
</PopoverTrigger>
172221
<PopoverContent
173-
className="p-0"
222+
className="z-[10001] p-0"
174223
align="center"
175224
sideOffset={10}
176225
onEscapeKeyDown={() => setIsPopoverOpen(false)}
177226
style={{
178227
width: "var(--radix-popover-trigger-width)",
179228
maxHeight: "var(--radix-popover-content-available-height)",
180229
}}
230+
ref={popoverElRef}
181231
>
182232
<div>
183233
{/* Search */}
184234
<div className="relative">
185235
<Input
186-
placeholder="Search"
236+
placeholder={props.searchPlaceholder || "Search"}
187237
value={searchValue}
188238
onChange={(e) => setSearchValue(e.target.value)}
189239
className="!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -193,7 +243,7 @@ export const MultiSelect = React.forwardRef<
193243
</div>
194244

195245
<ScrollShadow
196-
scrollableClassName="max-h-[350px] p-1"
246+
scrollableClassName="max-h-[min(calc(var(--radix-popover-content-available-height)-60px),350px)] p-1"
197247
className="rounded"
198248
>
199249
{/* List */}
@@ -213,7 +263,7 @@ export const MultiSelect = React.forwardRef<
213263
aria-selected={isSelected}
214264
onClick={() => toggleOption(option.value)}
215265
variant="ghost"
216-
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2"
266+
className="flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left"
217267
ref={
218268
i === optionsToShow.length - 1 ? lastItemRef : undefined
219269
}
@@ -229,7 +279,11 @@ export const MultiSelect = React.forwardRef<
229279
<CheckIcon className="size-4" />
230280
</div>
231281

232-
<span>{option.label}</span>
282+
<div className="min-w-0 grow">
283+
{props.renderOption
284+
? props.renderOption(option)
285+
: option.label}
286+
</div>
233287
</Button>
234288
);
235289
})}

apps/dashboard/src/@/components/ui/ScrollShadow/ScrollShadow.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export function ScrollShadow(props: {
125125
<div
126126
className={cn("no-scrollbar overflow-auto", props.scrollableClassName)}
127127
ref={scrollableEl}
128+
data-scrollable
128129
>
129130
{props.children}
130131
</div>

apps/dashboard/src/components/contract-components/contract-publish-form/NetworkDropdown.tsx

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1+
import { MultiSelect } from "@/components/blocks/multi-select";
2+
import { Badge } from "@/components/ui/badge";
13
import { Select } from "chakra-react-select";
24
import type { SizeProp } from "chakra-react-select";
3-
import { useMemo } from "react";
5+
import { useCallback, useMemo } from "react";
46
import { useFormContext } from "react-hook-form";
57
import { useAllChainsData } from "../../../hooks/chains/allChains";
68

79
interface NetworkDropdownProps {
810
useCleanChainName?: boolean;
911
isDisabled?: boolean;
10-
onMultiChange?: (networksEnabled: number[]) => void;
11-
onSingleChange?: (networksEnabled: number) => void;
12-
// biome-ignore lint/suspicious/noExplicitAny: FIXME
13-
value: any;
12+
onSingleChange: (networksEnabled: number) => void;
13+
value: number | undefined;
1414
size?: SizeProp;
1515
}
1616

@@ -20,7 +20,6 @@ function cleanChainName(chainName: string) {
2020

2121
export const NetworkDropdown: React.FC<NetworkDropdownProps> = ({
2222
useCleanChainName = true,
23-
onMultiChange,
2423
onSingleChange,
2524
value,
2625
size = "md",
@@ -56,27 +55,15 @@ export const NetworkDropdown: React.FC<NetworkDropdownProps> = ({
5655
<div className="flex w-full flex-row items-center gap-2">
5756
<Select
5857
size={size}
59-
placeholder={`${
60-
onSingleChange ? "Select a network" : "Select Networks"
61-
}`}
62-
isMulti={onMultiChange !== undefined}
58+
placeholder={"Select a network"}
6359
selectedOptionStyle="check"
6460
hideSelectedOptions={false}
6561
options={options}
6662
defaultValue={defaultValues}
67-
onChange={(selectedNetworks) => {
68-
if (selectedNetworks) {
69-
if (onMultiChange) {
70-
onMultiChange(
71-
// biome-ignore lint/suspicious/noExplicitAny: FIXME
72-
(selectedNetworks as any).map(
73-
({ value: val }: { value: string }) => val,
74-
),
75-
);
76-
} else if (onSingleChange) {
77-
onSingleChange(
78-
(selectedNetworks as { label: string; value: number }).value,
79-
);
63+
onChange={(selectedChain) => {
64+
if (selectedChain) {
65+
if (onSingleChange) {
66+
onSingleChange(selectedChain.value);
8067
}
8168
}
8269
}}
@@ -99,12 +86,77 @@ export const NetworkDropdown: React.FC<NetworkDropdownProps> = ({
9986
minWidth: "178px",
10087
}),
10188
}}
102-
value={
103-
onMultiChange
104-
? options.filter(({ value: val }) => value?.includes(val))
105-
: options.find(({ value: val }) => val === value)
106-
}
89+
value={options.find(({ value: val }) => val === value)}
10790
/>
10891
</div>
10992
);
11093
};
94+
95+
type Option = { label: string; value: string };
96+
97+
export function MultiNetworkSelector(props: {
98+
selectedChainIds: number[];
99+
onChange: (chainIds: number[]) => void;
100+
}) {
101+
const { allChains, idToChain } = useAllChainsData();
102+
103+
const options = useMemo(() => {
104+
return allChains.map((chain) => {
105+
return {
106+
label: cleanChainName(chain.name),
107+
value: String(chain.chainId),
108+
};
109+
});
110+
}, [allChains]);
111+
112+
const searchFn = useCallback(
113+
(option: Option, searchValue: string) => {
114+
const chain = idToChain.get(Number(option.value));
115+
if (!chain) {
116+
return false;
117+
}
118+
119+
if (Number.isInteger(Number.parseInt(searchValue))) {
120+
return String(chain.chainId).startsWith(searchValue);
121+
}
122+
return chain.name.toLowerCase().includes(searchValue.toLowerCase());
123+
},
124+
[idToChain],
125+
);
126+
127+
const renderOption = useCallback(
128+
(option: Option) => {
129+
const chain = idToChain.get(Number(option.value));
130+
if (!chain) {
131+
return option.label;
132+
}
133+
134+
return (
135+
<div className="flex justify-between gap-4">
136+
<span className="grow truncate text-left">
137+
{cleanChainName(chain.name)}
138+
</span>
139+
<Badge variant="outline" className="gap-2">
140+
<span className="text-muted-foreground">Chain ID</span>
141+
{chain.chainId}
142+
</Badge>
143+
</div>
144+
);
145+
},
146+
[idToChain],
147+
);
148+
149+
return (
150+
<MultiSelect
151+
searchPlaceholder="Search by Name or Chain Id"
152+
selectedValues={props.selectedChainIds.map(String)}
153+
options={options}
154+
onSelectedValuesChange={(chainIds) => {
155+
props.onChange(chainIds.map(Number));
156+
}}
157+
placeholder="Select Chains"
158+
overrideSearchFn={searchFn}
159+
renderOption={renderOption}
160+
/>
161+
);
162+
}

0 commit comments

Comments
 (0)