Skip to content

Commit f90d632

Browse files
author
Dylan Huang
committed
vite build
1 parent be999c8 commit f90d632

7 files changed

Lines changed: 258 additions & 137 deletions

File tree

vite-app/dist/assets/index-DDh9s5qV.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vite-app/dist/assets/index-DlnciKZd.js

Lines changed: 93 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vite-app/dist/assets/index-NdwHHvsG.js.map renamed to vite-app/dist/assets/index-DlnciKZd.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vite-app/dist/assets/index-DygPdC3a.css

Lines changed: 0 additions & 1 deletion
This file was deleted.

vite-app/dist/assets/index-NdwHHvsG.js

Lines changed: 0 additions & 93 deletions
This file was deleted.

vite-app/dist/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>EP | Log Viewer</title>
77
<link rel="icon" href="/assets/favicon-BkAAWQga.png" />
8-
<script type="module" crossorigin src="/assets/index-NdwHHvsG.js"></script>
9-
<link rel="stylesheet" crossorigin href="/assets/index-DygPdC3a.css">
8+
<script type="module" crossorigin src="/assets/index-DlnciKZd.js"></script>
9+
<link rel="stylesheet" crossorigin href="/assets/index-DDh9s5qV.css">
1010
</head>
1111
<body>
1212
<div id="root"></div>

vite-app/src/components/SearchableSelect.tsx

Lines changed: 161 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import React, { useState, useRef, useEffect } from "react";
1+
import React, {
2+
useState,
3+
useRef,
4+
useEffect,
5+
useMemo,
6+
useLayoutEffect,
7+
} from "react";
28
import { commonStyles } from "../styles/common";
39

410
interface SearchableSelectProps {
@@ -29,22 +35,29 @@ const SearchableSelect = React.forwardRef<
2935
) => {
3036
const [isOpen, setIsOpen] = useState(false);
3137
const [searchTerm, setSearchTerm] = useState("");
32-
const [filteredOptions, setFilteredOptions] = useState(options);
38+
// Memoize filtering to avoid extra state updates and re-renders
39+
const filteredOptions = useMemo(() => {
40+
const lowered = searchTerm.toLowerCase();
41+
if (!lowered) return options;
42+
return options.filter(
43+
(option) =>
44+
option.label.toLowerCase().includes(lowered) ||
45+
option.value.toLowerCase().includes(lowered)
46+
);
47+
}, [searchTerm, options]);
3348
const [dropdownPosition, setDropdownPosition] = useState<"left" | "right">(
3449
"left"
3550
);
51+
const [dropdownWidth, setDropdownWidth] = useState<number | undefined>(
52+
undefined
53+
);
3654
const [highlightedIndex, setHighlightedIndex] = useState(-1);
3755
const containerRef = useRef<HTMLDivElement>(null);
3856
const inputRef = useRef<HTMLInputElement>(null);
3957

58+
// Reset highlighted index when the search or options change
4059
useEffect(() => {
41-
const filtered = options.filter(
42-
(option) =>
43-
option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
44-
option.value.toLowerCase().includes(searchTerm.toLowerCase())
45-
);
46-
setFilteredOptions(filtered);
47-
setHighlightedIndex(-1); // Reset highlighted index when options change
60+
setHighlightedIndex(-1);
4861
}, [searchTerm, options]);
4962

5063
useEffect(() => {
@@ -100,24 +113,50 @@ const SearchableSelect = React.forwardRef<
100113
}
101114
};
102115

103-
const calculateDropdownPosition = () => {
104-
if (!containerRef.current) return "left";
116+
// Compute side and width so the dropdown can be wider than the trigger but still fit viewport
117+
const computeDropdownLayout = (): {
118+
side: "left" | "right";
119+
width: number;
120+
} => {
121+
if (!containerRef.current) return { side: "left", width: 240 };
105122

106123
const rect = containerRef.current.getBoundingClientRect();
107124
const windowWidth = window.innerWidth;
108-
const estimatedDropdownWidth = 300; // Approximate width for dropdown content
125+
const VIEWPORT_MARGIN = 16; // px
126+
const EXTRA_WIDTH = 240; // desired extra room beyond trigger
127+
const MAX_WIDTH = 600; // hard cap
128+
129+
const desired = Math.min(rect.width + EXTRA_WIDTH, MAX_WIDTH);
109130

110-
// If dropdown would overflow right edge, position it to the left
111-
if (rect.left + estimatedDropdownWidth > windowWidth) {
112-
return "right";
131+
const spaceRight = windowWidth - rect.left - VIEWPORT_MARGIN; // space if anchored left-0
132+
const spaceLeft = rect.right - VIEWPORT_MARGIN; // space if anchored right-0
133+
134+
const widthRight = Math.max(rect.width, Math.min(desired, spaceRight));
135+
const widthLeft = Math.max(rect.width, Math.min(desired, spaceLeft));
136+
137+
// Prefer the side that can accommodate closer to desired width
138+
if (widthRight >= widthLeft && widthRight >= rect.width) {
139+
return { side: "left", width: widthRight };
113140
}
114-
return "left";
141+
if (widthLeft > widthRight && widthLeft >= rect.width) {
142+
return { side: "right", width: widthLeft };
143+
}
144+
145+
// Fallback: clamp to viewport with preference to right anchoring if less overflow
146+
const clamped = Math.max(
147+
rect.width,
148+
Math.min(desired, windowWidth - VIEWPORT_MARGIN * 2)
149+
);
150+
const preferRight = rect.left < windowWidth - rect.right;
151+
return { side: preferRight ? "left" : "right", width: clamped };
115152
};
116153

117154
const handleToggle = () => {
118155
if (!disabled) {
119156
if (!isOpen) {
120-
setDropdownPosition(calculateDropdownPosition());
157+
const layout = computeDropdownLayout();
158+
setDropdownPosition(layout.side);
159+
setDropdownWidth(layout.width);
121160
}
122161
setIsOpen(!isOpen);
123162
if (!isOpen) {
@@ -126,7 +165,62 @@ const SearchableSelect = React.forwardRef<
126165
}
127166
};
128167

129-
const selectedOption = options.find((option) => option.value === value);
168+
const selectedOption = useMemo(
169+
() => options.find((option) => option.value === value),
170+
[options, value]
171+
);
172+
173+
// --- Simple list virtualization for large option sets ---
174+
const listRef = useRef<HTMLDivElement>(null);
175+
const [scrollTop, setScrollTop] = useState(0);
176+
const [containerHeight, setContainerHeight] = useState(192); // Tailwind max-h-48 (~192px)
177+
178+
const ITEM_HEIGHT = 32; // Approximate item height in px
179+
const OVERSCAN = 5;
180+
181+
const totalItems = filteredOptions.length;
182+
const visibleCount = Math.max(1, Math.ceil(containerHeight / ITEM_HEIGHT));
183+
const startIndex = Math.max(
184+
0,
185+
Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN
186+
);
187+
const endIndex = Math.min(
188+
totalItems,
189+
startIndex + visibleCount + OVERSCAN * 2
190+
);
191+
const topPaddingHeight = startIndex * ITEM_HEIGHT;
192+
const bottomPaddingHeight = Math.max(
193+
0,
194+
(totalItems - endIndex) * ITEM_HEIGHT
195+
);
196+
197+
// Measure container height when open and on resize
198+
useLayoutEffect(() => {
199+
if (!isOpen) return;
200+
const el = listRef.current;
201+
if (!el) return;
202+
203+
const measure = () => {
204+
setContainerHeight(el.clientHeight || 192);
205+
};
206+
measure();
207+
208+
const ro = new ResizeObserver(measure);
209+
ro.observe(el);
210+
return () => ro.disconnect();
211+
}, [isOpen]);
212+
213+
// Reset scroll when opening or when search changes
214+
useEffect(() => {
215+
if (isOpen && listRef.current) {
216+
listRef.current.scrollTop = 0;
217+
setScrollTop(0);
218+
}
219+
}, [isOpen, searchTerm]);
220+
221+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
222+
setScrollTop((e.target as HTMLDivElement).scrollTop);
223+
};
130224

131225
return (
132226
<div ref={containerRef} className={`relative ${className}`}>
@@ -151,7 +245,10 @@ const SearchableSelect = React.forwardRef<
151245
`}
152246
style={{ boxShadow: commonStyles.input.shadow }}
153247
>
154-
<span className={value ? "text-gray-900" : "text-gray-400"}>
248+
<span
249+
className={value ? "text-gray-900" : "text-gray-400"}
250+
title={selectedOption ? selectedOption.label : placeholder}
251+
>
155252
{selectedOption ? selectedOption.label : placeholder}
156253
</span>
157254
<svg
@@ -173,13 +270,13 @@ const SearchableSelect = React.forwardRef<
173270

174271
{isOpen && (
175272
<div
176-
className={`absolute z-50 w-max min-w-full mt-1 ${
273+
className={`absolute z-50 mt-1 ${
177274
commonStyles.input.base
178-
} max-h-60 overflow-auto ${
275+
} max-h-60 overflow-x-hidden ${
179276
dropdownPosition === "right" ? "right-0" : "left-0"
180277
}`}
181278
style={{
182-
maxWidth: `min(calc(100vw - 2rem), 500px)`,
279+
width: dropdownWidth,
183280
right: dropdownPosition === "right" ? "0" : undefined,
184281
left: dropdownPosition === "left" ? "0" : undefined,
185282
boxShadow: commonStyles.input.shadow,
@@ -196,33 +293,57 @@ const SearchableSelect = React.forwardRef<
196293
onChange={(e) => setSearchTerm(e.target.value)}
197294
onKeyDown={handleKeyDown}
198295
placeholder="Search..."
199-
className={`${commonStyles.input.base} ${commonStyles.input.size.sm} w-full min-w-48`}
296+
className={`${commonStyles.input.base} ${commonStyles.input.size.sm} w-full`}
200297
style={{ boxShadow: commonStyles.input.shadow }}
201298
role="searchbox"
202299
aria-label="Search options"
203300
/>
204301
</div>
205302
<div
206-
className="max-h-48 overflow-auto"
303+
ref={listRef}
304+
className="max-h-48 overflow-y-auto overflow-x-hidden"
207305
role="listbox"
208306
aria-label="Options"
307+
onScroll={handleScroll}
209308
>
210-
{filteredOptions.length > 0 ? (
211-
filteredOptions.map((option, index) => (
212-
<div
213-
key={option.value}
214-
onClick={() => handleSelect(option.value)}
215-
onMouseEnter={() => setHighlightedIndex(index)}
216-
className={`px-3 py-2 text-xs font-medium cursor-pointer hover:bg-gray-100 text-gray-700 border-b border-gray-100 last:border-b-0 ${
217-
highlightedIndex === index ? "bg-gray-100" : ""
218-
}`}
219-
role="option"
220-
aria-selected={highlightedIndex === index}
221-
tabIndex={-1}
222-
>
223-
{option.label}
224-
</div>
225-
))
309+
{totalItems > 0 ? (
310+
<>
311+
{topPaddingHeight > 0 && (
312+
<div style={{ height: topPaddingHeight }} />
313+
)}
314+
{filteredOptions
315+
.slice(startIndex, endIndex)
316+
.map((option, i) => {
317+
const absoluteIndex = startIndex + i;
318+
return (
319+
<div
320+
key={option.value}
321+
onClick={() => handleSelect(option.value)}
322+
onMouseEnter={() =>
323+
setHighlightedIndex(absoluteIndex)
324+
}
325+
className={`px-3 py-2 text-xs font-medium cursor-pointer hover:bg-gray-100 text-gray-700 border-b border-gray-100 last:border-b-0 ${
326+
highlightedIndex === absoluteIndex
327+
? "bg-gray-100"
328+
: ""
329+
}`}
330+
role="option"
331+
aria-selected={highlightedIndex === absoluteIndex}
332+
aria-label={option.label}
333+
tabIndex={-1}
334+
style={{ height: ITEM_HEIGHT }}
335+
title={option.label}
336+
>
337+
<div className="overflow-x-auto whitespace-nowrap">
338+
{option.label}
339+
</div>
340+
</div>
341+
);
342+
})}
343+
{bottomPaddingHeight > 0 && (
344+
<div style={{ height: bottomPaddingHeight }} />
345+
)}
346+
</>
226347
) : (
227348
<div className="px-3 py-2 text-xs font-medium text-gray-500">
228349
No options found

0 commit comments

Comments
 (0)