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 -