feat: improve TagCombobox positioning and SearchBar layout
- Updated TagCombobox to use a portal for dropdown rendering, enhancing positioning based on the input field. - Adjusted SearchBar layout to ensure responsive behavior with full width on smaller screens. - Added state management for dropdown panel style to improve user experience.
cognitive-glitch committed
Nov 25, 2025 at 00:52 UTC
21e847a491697978c7c18727a270d0c11916250a
2 files changed
+68
-31
cmd/relay-server/frontend/src/components/SearchBar.tsx
+1
-1
@@ -79,7 +79,7 @@ export function SearchBar({
79
</SelectContent>
80
</Select>
81
82
- <div className="relative flex min-w-[320px] flex-1">
82
+ <div className="relative flex w-full sm:w-auto sm:min-w-[320px] flex-1">
83
<TagCombobox
84
availableTags={availableTags}
85
selectedTags={selectedTags}
cmd/relay-server/frontend/src/components/TagCombobox.tsx
+67
-30
@@ -1,4 +1,5 @@
1
-import { useEffect, useMemo, useRef, useState } from "react";
1
+import React, { useEffect, useMemo, useRef, useState } from "react";
2
+import { createPortal } from "react-dom";
3
import { Button } from "@/components/ui/button";
4
import type { TagMode } from "@/types/filters";
5
import { cn } from "@/lib/utils";
@@ -25,6 +26,8 @@ export function TagCombobox({
26
const [activeIndex, setActiveIndex] = useState(0);
27
const listId = "tag-combobox-list";
28
const inputRef = useRef<HTMLInputElement | null>(null);
29
+ const containerRef = useRef<HTMLDivElement | null>(null);
30
+ const [panelStyle, setPanelStyle] = useState<React.CSSProperties>();
31
32
const filtered = useMemo(() => {
33
const query = inputValue.trim().toLowerCase();
@@ -39,6 +42,29 @@ export function TagCombobox({
42
setActiveIndex(0);
43
}, [filtered.length]);
44
45
+ useEffect(() => {
46
+ if (!open) return;
47
+ const updatePosition = () => {
48
+ const el = containerRef.current;
49
+ if (!el) return;
50
+ const rect = el.getBoundingClientRect();
51
+ setPanelStyle({
52
+ position: "absolute",
53
+ top: rect.bottom + window.scrollY + 4,
54
+ left: rect.left + window.scrollX,
55
+ width: rect.width,
56
+ zIndex: 50,
57
+ });
58
+ };
59
+ updatePosition();
60
+ window.addEventListener("resize", updatePosition);
61
+ window.addEventListener("scroll", updatePosition, true);
62
+ return () => {
63
+ window.removeEventListener("resize", updatePosition);
64
+ window.removeEventListener("scroll", updatePosition, true);
65
+ };
66
+ }, [open]);
67
+
68
const addTag = (tag: string) => {
69
if (!tag || selectedTags.includes(tag)) return;
70
onAdd(tag);
@@ -75,8 +101,11 @@ export function TagCombobox({
101
};
102
103
return (
78
- <div className="flex min-w-[320px] flex-1 items-center gap-2 overflow-hidden">
79
- <div className="flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-border bg-background px-2 py-1">
104
+ <div
105
+ ref={containerRef}
106
+ className="flex w-full sm:w-auto sm:min-w-[320px] flex-1 items-center gap-2 overflow-visible"
107
+ >
108
+ <div className="flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-border bg-background px-2 py-1.5 min-h-10">
109
<div className="flex flex-wrap items-center gap-2 overflow-hidden">
110
{selectedTags.map((tag) => (
111
<Button
@@ -111,11 +140,11 @@ export function TagCombobox({
140
</div>
141
</div>
142
114
- <div className="flex items-center rounded-md bg-border text-xs font-semibold text-foreground/80 overflow-hidden">
143
+ <div className="flex items-center rounded-md bg-border text-xs font-semibold text-foreground/80 overflow-hidden h-10 flex-shrink-0">
144
<button
145
type="button"
146
className={cn(
118
- "px-3 py-2 transition-colors",
147
+ "h-full px-3 flex items-center justify-center transition-colors",
148
mode === "OR" ? "bg-primary text-black" : "hover:bg-border/80"
149
)}
150
aria-pressed={mode === "OR"}
@@ -126,7 +155,7 @@ export function TagCombobox({
155
<button
156
type="button"
157
className={cn(
129
- "px-3 py-2 transition-colors",
158
+ "h-full px-3 flex items-center justify-center transition-colors",
159
mode === "AND" ? "bg-primary text-black" : "hover:bg-border/80"
160
)}
161
aria-pressed={mode === "AND"}
@@ -136,30 +165,38 @@ export function TagCombobox({
165
</button>
166
</div>
167
139
- {open && filtered.length > 0 && (
140
- <div className="absolute z-20 mt-1 w-[320px] rounded-lg border border-border bg-background shadow-lg">
141
- <ul id={listId} role="listbox" className="max-h-56 overflow-auto py-1">
142
- {filtered.map((tag, idx) => (
143
- <li key={tag} role="option" aria-selected={idx === activeIndex}>
144
- <button
145
- type="button"
146
- className={cn(
147
- "flex w-full items-center px-3 py-2 text-left text-sm transition-colors",
148
- idx === activeIndex
149
- ? "bg-primary/20 text-foreground"
150
- : "hover:bg-border/60"
151
- )}
152
- onMouseDown={(e) => e.preventDefault()}
153
- onClick={() => addTag(tag)}
154
- >
155
- {tag}
156
- </button>
157
- </li>
158
- ))}
159
- </ul>
160
- </div>
161
- )}
168
+ {open && filtered.length > 0 && panelStyle &&
169
+ createPortal(
170
+ <div
171
+ style={panelStyle}
172
+ className="rounded-lg border border-border bg-background shadow-lg"
173
+ >
174
+ <ul
175
+ id={listId}
176
+ role="listbox"
177
+ className="max-h-56 overflow-auto py-1"
178
+ >
179
+ {filtered.map((tag, idx) => (
180
+ <li key={tag} role="option" aria-selected={idx === activeIndex}>
181
+ <button
182
+ type="button"
183
+ className={cn(
184
+ "flex w-full items-center px-3 py-2 text-left text-sm transition-colors",
185
+ idx === activeIndex
186
+ ? "bg-primary/20 text-foreground"
187
+ : "hover:bg-border/60"
188
+ )}
189
+ onMouseDown={(e) => e.preventDefault()}
190
+ onClick={() => addTag(tag)}
191
+ >
192
+ {tag}
193
+ </button>
194
+ </li>
195
+ ))}
196
+ </ul>
197
+ </div>,
198
+ document.body
199
+ )}
200
</div>
201
);
202
}
165
-