Skip to content

Commit 656c994

Browse files
author
Dylan Huang
committed
SearchableSelect
1 parent bcdede1 commit 656c994

2 files changed

Lines changed: 200 additions & 51 deletions

File tree

vite-app/src/components/PivotTab.tsx

Lines changed: 44 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { observer } from "mobx-react";
22
import PivotTable from "./PivotTable";
33
import Select from "./Select";
4+
import SearchableSelect from "./SearchableSelect";
45
import Button from "./Button";
56
import FilterInput from "./FilterInput";
67
import { state } from "../App";
@@ -41,19 +42,17 @@ const FieldSelector = ({
4142
<div className="space-y-2">
4243
{fields.map((field, index) => (
4344
<div key={index} className="flex items-center space-x-2">
44-
<Select
45+
<SearchableSelect
4546
value={field}
46-
onChange={(e) => onFieldChange(index, e.target.value)}
47+
onChange={(value) => onFieldChange(index, value)}
48+
options={[
49+
{ value: "", label: "Select a field..." },
50+
...(availableKeys?.map((key) => ({ value: key, label: key })) ||
51+
[]),
52+
]}
4753
size="sm"
4854
className="min-w-48"
49-
>
50-
<option value="">Select a field...</option>
51-
{availableKeys?.map((key) => (
52-
<option key={key} value={key}>
53-
{key}
54-
</option>
55-
))}
56-
</Select>
55+
/>
5756
{fields.length > 0 && (
5857
<button
5958
onClick={() => onRemoveField(index)}
@@ -90,19 +89,16 @@ const SingleFieldSelector = ({
9089
}) => (
9190
<div className="mb-4">
9291
<div className="text-xs font-medium text-gray-700 mb-2">{title}:</div>
93-
<Select
92+
<SearchableSelect
9493
value={field}
95-
onChange={(e) => onFieldChange(e.target.value)}
94+
onChange={(value) => onFieldChange(value)}
95+
options={[
96+
{ value: "", label: "Select a field..." },
97+
...(availableKeys?.map((key) => ({ value: key, label: key })) || []),
98+
]}
9699
size="sm"
97100
className="min-w-48"
98-
>
99-
<option value="">Select a field...</option>
100-
{availableKeys?.map((key) => (
101-
<option key={key} value={key}>
102-
{key}
103-
</option>
104-
))}
105-
</Select>
101+
/>
106102
</div>
107103
);
108104

@@ -117,18 +113,19 @@ const AggregatorSelector = ({
117113
<div className="text-xs font-medium text-gray-700 mb-2">
118114
Aggregation Method:
119115
</div>
120-
<Select
116+
<SearchableSelect
121117
value={aggregator}
122-
onChange={(e) => onAggregatorChange(e.target.value)}
118+
onChange={(value) => onAggregatorChange(value)}
119+
options={[
120+
{ value: "count", label: "Count" },
121+
{ value: "sum", label: "Sum" },
122+
{ value: "avg", label: "Average" },
123+
{ value: "min", label: "Minimum" },
124+
{ value: "max", label: "Maximum" },
125+
]}
123126
size="sm"
124127
className="min-w-48"
125-
>
126-
<option value="count">Count</option>
127-
<option value="sum">Sum</option>
128-
<option value="avg">Average</option>
129-
<option value="min">Minimum</option>
130-
<option value="max">Maximum</option>
131-
</Select>
128+
/>
132129
</div>
133130
);
134131

@@ -168,37 +165,33 @@ const FilterSelector = ({
168165

169166
return (
170167
<div key={index} className="flex items-center space-x-2">
171-
<Select
168+
<SearchableSelect
172169
value={filter.field}
173-
onChange={(e) => {
174-
const newField = e.target.value;
170+
onChange={(value) => {
171+
const newField = value;
175172
const newType = getFieldType(newField);
176173
updateFilter(index, { field: newField, type: newType });
177174
}}
175+
options={[
176+
{ value: "", label: "Select a field..." },
177+
...(availableKeys?.map((key) => ({
178+
value: key,
179+
label: key,
180+
})) || []),
181+
]}
178182
size="sm"
179183
className="min-w-48"
180-
>
181-
<option value="">Select a field...</option>
182-
{availableKeys?.map((key) => (
183-
<option key={key} value={key}>
184-
{key}
185-
</option>
186-
))}
187-
</Select>
188-
<Select
184+
/>
185+
<SearchableSelect
189186
value={filter.operator}
190-
onChange={(e) =>
191-
updateFilter(index, { operator: e.target.value })
192-
}
187+
onChange={(value) => updateFilter(index, { operator: value })}
188+
options={operators.map((op) => ({
189+
value: op.value,
190+
label: op.label,
191+
}))}
193192
size="sm"
194193
className="min-w-32"
195-
>
196-
{operators.map((op) => (
197-
<option key={op.value} value={op.value}>
198-
{op.label}
199-
</option>
200-
))}
201-
</Select>
194+
/>
202195
<FilterInput
203196
filter={filter}
204197
index={index}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { useState, useRef, useEffect } from "react";
2+
import { commonStyles } from "../styles/common";
3+
4+
interface SearchableSelectProps {
5+
options: { value: string; label: string }[];
6+
value: string;
7+
onChange: (value: string) => void;
8+
placeholder?: string;
9+
size?: "sm" | "md";
10+
className?: string;
11+
disabled?: boolean;
12+
}
13+
14+
const SearchableSelect = React.forwardRef<
15+
HTMLDivElement,
16+
SearchableSelectProps
17+
>(
18+
(
19+
{
20+
options,
21+
value,
22+
onChange,
23+
placeholder = "Select...",
24+
size = "sm",
25+
className = "",
26+
disabled = false,
27+
},
28+
ref
29+
) => {
30+
const [isOpen, setIsOpen] = useState(false);
31+
const [searchTerm, setSearchTerm] = useState("");
32+
const [filteredOptions, setFilteredOptions] = useState(options);
33+
const containerRef = useRef<HTMLDivElement>(null);
34+
const inputRef = useRef<HTMLInputElement>(null);
35+
36+
useEffect(() => {
37+
const filtered = options.filter(
38+
(option) =>
39+
option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
40+
option.value.toLowerCase().includes(searchTerm.toLowerCase())
41+
);
42+
setFilteredOptions(filtered);
43+
}, [searchTerm, options]);
44+
45+
useEffect(() => {
46+
const handleClickOutside = (event: MouseEvent) => {
47+
if (
48+
containerRef.current &&
49+
!containerRef.current.contains(event.target as Node)
50+
) {
51+
setIsOpen(false);
52+
setSearchTerm("");
53+
}
54+
};
55+
56+
document.addEventListener("mousedown", handleClickOutside);
57+
return () =>
58+
document.removeEventListener("mousedown", handleClickOutside);
59+
}, []);
60+
61+
const handleSelect = (optionValue: string) => {
62+
onChange(optionValue);
63+
setIsOpen(false);
64+
setSearchTerm("");
65+
};
66+
67+
const handleToggle = () => {
68+
if (!disabled) {
69+
setIsOpen(!isOpen);
70+
if (!isOpen) {
71+
setTimeout(() => inputRef.current?.focus(), 0);
72+
}
73+
}
74+
};
75+
76+
const selectedOption = options.find((option) => option.value === value);
77+
78+
return (
79+
<div ref={containerRef} className={`relative ${className}`}>
80+
<div
81+
ref={ref}
82+
onClick={handleToggle}
83+
className={`
84+
${commonStyles.input.base}
85+
${commonStyles.input.size[size]}
86+
cursor-pointer flex items-center justify-between
87+
${disabled ? "opacity-50 cursor-not-allowed" : ""}
88+
`}
89+
style={{ boxShadow: commonStyles.input.shadow }}
90+
>
91+
<span className={value ? "text-gray-900" : "text-gray-400"}>
92+
{selectedOption ? selectedOption.label : placeholder}
93+
</span>
94+
<svg
95+
className={`w-4 h-4 text-gray-400 transition-transform ${
96+
isOpen ? "rotate-180" : ""
97+
}`}
98+
fill="none"
99+
stroke="currentColor"
100+
viewBox="0 0 24 24"
101+
>
102+
<path
103+
strokeLinecap="round"
104+
strokeLinejoin="round"
105+
strokeWidth={2}
106+
d="M19 9l-7 7-7-7"
107+
/>
108+
</svg>
109+
</div>
110+
111+
{isOpen && (
112+
<div className="absolute z-50 w-max min-w-full mt-1 bg-white border border-gray-200 rounded-md max-h-60 overflow-auto">
113+
<div className="p-2 border-b border-gray-200">
114+
<input
115+
ref={inputRef}
116+
type="text"
117+
value={searchTerm}
118+
onChange={(e) => setSearchTerm(e.target.value)}
119+
placeholder="Search..."
120+
className={`${commonStyles.input.base} ${commonStyles.input.size.sm} w-full`}
121+
style={{ boxShadow: commonStyles.input.shadow }}
122+
onKeyDown={(e) => {
123+
if (e.key === "Escape") {
124+
setIsOpen(false);
125+
setSearchTerm("");
126+
}
127+
}}
128+
/>
129+
</div>
130+
<div className="max-h-48 overflow-auto">
131+
{filteredOptions.length > 0 ? (
132+
filteredOptions.map((option) => (
133+
<div
134+
key={option.value}
135+
onClick={() => handleSelect(option.value)}
136+
className="px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 text-gray-700 border-b border-gray-100 last:border-b-0"
137+
>
138+
{option.label}
139+
</div>
140+
))
141+
) : (
142+
<div className="px-3 py-2 text-sm text-gray-500">
143+
No options found
144+
</div>
145+
)}
146+
</div>
147+
</div>
148+
)}
149+
</div>
150+
);
151+
}
152+
);
153+
154+
SearchableSelect.displayName = "SearchableSelect";
155+
156+
export default SearchableSelect;

0 commit comments

Comments
 (0)