Skip to content

Commit ffb5ed5

Browse files
authored
Merge pull request samqin123#30 from zhuangdaz/fancy-spin
Add hover-triggered language selector with flip animation
2 parents f11fd6f + 62f767b commit ffb5ed5

File tree

2 files changed

+397
-135
lines changed

2 files changed

+397
-135
lines changed

components/language-selector.tsx

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
"use client";
2+
3+
import { useState, useEffect, useCallback, useRef } from "react";
4+
import { createPortal } from "react-dom";
5+
import { Button } from "@/components/ui/button";
6+
import { Input } from "@/components/ui/input";
7+
import { Languages, ChevronDown, CheckCircle2, Circle, Search } from "lucide-react";
8+
import { SUPPORTED_LANGUAGES } from "@/lib/language-utils";
9+
import { cn } from "@/lib/utils";
10+
11+
interface LanguageSelectorProps {
12+
activeTab: "transcript" | "chat" | "notes";
13+
selectedLanguage: string | null;
14+
isAuthenticated?: boolean;
15+
onTabSwitch: (tab: "transcript" | "chat" | "notes") => void;
16+
onLanguageChange?: (languageCode: string | null) => void;
17+
onRequestSignIn?: () => void;
18+
}
19+
20+
interface LanguageSelectorMenuProps {
21+
chevronRef: React.RefObject<HTMLButtonElement | null>;
22+
menuRef: React.RefObject<HTMLDivElement | null>;
23+
filteredLanguages: Array<typeof SUPPORTED_LANGUAGES[number]>;
24+
currentLanguageCode: string;
25+
selectedLanguage: string | null;
26+
isAuthenticated: boolean;
27+
languageSearch: string;
28+
onLanguageSearchChange: (value: string) => void;
29+
onLanguageSelect: (langCode: string) => void;
30+
onRequestSignIn?: () => void;
31+
onMenuMouseEnter: () => void;
32+
onMenuMouseLeave: () => void;
33+
}
34+
35+
export function LanguageSelector({
36+
activeTab,
37+
selectedLanguage,
38+
isAuthenticated = false,
39+
onTabSwitch,
40+
onLanguageChange,
41+
onRequestSignIn,
42+
}: LanguageSelectorProps) {
43+
const [isMenuOpen, setIsMenuOpen] = useState(false);
44+
const [languageSearch, setLanguageSearch] = useState("");
45+
const [isMounted, setIsMounted] = useState(false);
46+
47+
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
48+
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
49+
const containerRef = useRef<HTMLDivElement>(null);
50+
const chevronRef = useRef<HTMLButtonElement>(null);
51+
const menuRef = useRef<HTMLDivElement>(null);
52+
53+
// Get current language - null or 'en' means English
54+
const currentLanguageCode = selectedLanguage || 'en';
55+
56+
// Filter languages based on search
57+
const filteredLanguages = SUPPORTED_LANGUAGES.filter(lang =>
58+
lang.name.toLowerCase().includes(languageSearch.toLowerCase()) ||
59+
lang.nativeName.toLowerCase().includes(languageSearch.toLowerCase())
60+
);
61+
62+
// Track mount state for portal rendering
63+
useEffect(() => {
64+
setIsMounted(true);
65+
}, []);
66+
67+
// Cleanup timeouts on unmount
68+
useEffect(() => {
69+
return () => {
70+
if (hoverTimeoutRef.current) {
71+
clearTimeout(hoverTimeoutRef.current);
72+
}
73+
if (closeTimeoutRef.current) {
74+
clearTimeout(closeTimeoutRef.current);
75+
}
76+
};
77+
}, []);
78+
79+
// Handle chevron hover - start delay timer
80+
const handleChevronMouseEnter = useCallback(() => {
81+
if (!isMenuOpen && !hoverTimeoutRef.current) {
82+
hoverTimeoutRef.current = setTimeout(() => {
83+
setIsMenuOpen(true);
84+
setLanguageSearch("");
85+
hoverTimeoutRef.current = null;
86+
}, 175); // 150-200ms range midpoint
87+
}
88+
}, [isMenuOpen]);
89+
90+
// Handle chevron hover leave - cancel timer before it fires
91+
const handleChevronMouseLeave = useCallback(() => {
92+
if (hoverTimeoutRef.current) {
93+
clearTimeout(hoverTimeoutRef.current);
94+
hoverTimeoutRef.current = null;
95+
}
96+
}, []);
97+
98+
// Handle container mouse leave - start close timer
99+
const handleContainerMouseLeave = useCallback((e: React.MouseEvent) => {
100+
if (!isMenuOpen) return;
101+
102+
// Cancel any existing close timeout
103+
if (closeTimeoutRef.current) {
104+
clearTimeout(closeTimeoutRef.current);
105+
}
106+
107+
// Start a new close timeout
108+
closeTimeoutRef.current = setTimeout(() => {
109+
// Check if mouse is not over menu before closing
110+
if (menuRef.current && !menuRef.current.contains(document.elementFromPoint(e.clientX, e.clientY))) {
111+
setIsMenuOpen(false);
112+
setLanguageSearch("");
113+
}
114+
closeTimeoutRef.current = null;
115+
}, 100);
116+
}, [isMenuOpen]);
117+
118+
// Handle menu mouse enter - cancel close timer
119+
const handleMenuMouseEnter = useCallback(() => {
120+
if (closeTimeoutRef.current) {
121+
clearTimeout(closeTimeoutRef.current);
122+
closeTimeoutRef.current = null;
123+
}
124+
}, []);
125+
126+
// Handle menu mouse leave - close menu after delay
127+
const handleMenuMouseLeave = useCallback(() => {
128+
if (closeTimeoutRef.current) {
129+
clearTimeout(closeTimeoutRef.current);
130+
}
131+
132+
closeTimeoutRef.current = setTimeout(() => {
133+
setIsMenuOpen(false);
134+
setLanguageSearch("");
135+
closeTimeoutRef.current = null;
136+
}, 100);
137+
}, []);
138+
139+
// Handle language selection
140+
const handleLanguageSelect = useCallback((langCode: string) => {
141+
// Handle auth check
142+
if (!isAuthenticated && langCode !== 'en') {
143+
onRequestSignIn?.();
144+
return;
145+
}
146+
147+
// Clear any pending close timeout
148+
if (closeTimeoutRef.current) {
149+
clearTimeout(closeTimeoutRef.current);
150+
closeTimeoutRef.current = null;
151+
}
152+
153+
// Toggle selection if clicking current language
154+
const newLanguage = langCode === currentLanguageCode && selectedLanguage !== null
155+
? null
156+
: langCode;
157+
158+
onLanguageChange?.(newLanguage);
159+
160+
// Only switch to Transcript tab if NOT already on it
161+
if (activeTab !== 'transcript') {
162+
onTabSwitch('transcript');
163+
}
164+
165+
setIsMenuOpen(false);
166+
setLanguageSearch("");
167+
}, [isAuthenticated, currentLanguageCode, selectedLanguage, activeTab, onLanguageChange, onTabSwitch, onRequestSignIn]);
168+
169+
// Handle outside click - close menu without tab switch
170+
useEffect(() => {
171+
if (!isMenuOpen) return;
172+
173+
const handleOutsideClick = (e: MouseEvent) => {
174+
const target = e.target as Node;
175+
// Check if click is outside both container and menu
176+
if (!containerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
177+
setIsMenuOpen(false);
178+
setLanguageSearch("");
179+
// NOTE: Explicitly NOT calling onTabSwitch here
180+
}
181+
};
182+
183+
// Use mousedown for faster response, but add a small delay to ensure
184+
// the language selection handler fires first
185+
const timeoutId = setTimeout(() => {
186+
document.addEventListener("mousedown", handleOutsideClick);
187+
}, 0);
188+
189+
return () => {
190+
clearTimeout(timeoutId);
191+
document.removeEventListener("mousedown", handleOutsideClick);
192+
};
193+
}, [isMenuOpen]);
194+
195+
return (
196+
<>
197+
<div
198+
ref={containerRef}
199+
className={cn(
200+
"flex items-center gap-0 rounded-2xl w-full",
201+
activeTab === "transcript"
202+
? "bg-neutral-100"
203+
: "hover:bg-white/50"
204+
)}
205+
onMouseLeave={handleContainerMouseLeave}
206+
>
207+
<Button
208+
variant="ghost"
209+
size="sm"
210+
onClick={() => onTabSwitch("transcript")}
211+
className={cn(
212+
"flex-1 justify-center gap-2 rounded-l-2xl rounded-r-none border-0",
213+
activeTab === "transcript"
214+
? "text-foreground hover:bg-neutral-100"
215+
: "text-muted-foreground hover:text-foreground hover:bg-transparent"
216+
)}
217+
>
218+
<Languages className="h-4 w-4" />
219+
Transcript
220+
</Button>
221+
<Button
222+
ref={chevronRef}
223+
variant="ghost"
224+
size="sm"
225+
onMouseEnter={handleChevronMouseEnter}
226+
onMouseLeave={handleChevronMouseLeave}
227+
onClick={() => setIsMenuOpen(!isMenuOpen)}
228+
className={cn(
229+
"rounded-r-2xl rounded-l-none border-0 !pl-0",
230+
activeTab === "transcript"
231+
? "text-foreground hover:bg-neutral-100"
232+
: "text-muted-foreground hover:text-foreground hover:bg-transparent"
233+
)}
234+
>
235+
<ChevronDown
236+
className="h-3 w-3 opacity-50"
237+
style={{
238+
transform: isMenuOpen ? "rotate(0deg)" : "rotate(180deg)",
239+
transition: "transform 200ms cubic-bezier(0.4, 0, 0.2, 1)",
240+
}}
241+
/>
242+
</Button>
243+
</div>
244+
245+
{isMounted && isMenuOpen && (
246+
<LanguageSelectorMenu
247+
chevronRef={chevronRef}
248+
menuRef={menuRef}
249+
filteredLanguages={filteredLanguages}
250+
currentLanguageCode={currentLanguageCode}
251+
selectedLanguage={selectedLanguage}
252+
isAuthenticated={isAuthenticated}
253+
languageSearch={languageSearch}
254+
onLanguageSearchChange={setLanguageSearch}
255+
onLanguageSelect={handleLanguageSelect}
256+
onRequestSignIn={onRequestSignIn}
257+
onMenuMouseEnter={handleMenuMouseEnter}
258+
onMenuMouseLeave={handleMenuMouseLeave}
259+
/>
260+
)}
261+
</>
262+
);
263+
}
264+
265+
function LanguageSelectorMenu({
266+
chevronRef,
267+
menuRef,
268+
filteredLanguages,
269+
currentLanguageCode,
270+
selectedLanguage,
271+
isAuthenticated,
272+
languageSearch,
273+
onLanguageSearchChange,
274+
onLanguageSelect,
275+
onRequestSignIn,
276+
onMenuMouseEnter,
277+
onMenuMouseLeave,
278+
}: LanguageSelectorMenuProps) {
279+
const [position, setPosition] = useState({ top: 0, left: 0 });
280+
281+
// Calculate and update menu position
282+
useEffect(() => {
283+
if (!chevronRef?.current) return;
284+
285+
const updatePosition = () => {
286+
const rect = chevronRef.current!.getBoundingClientRect();
287+
setPosition({
288+
top: rect.bottom + 4,
289+
left: rect.left - 200, // Align with existing alignOffset
290+
});
291+
};
292+
293+
updatePosition();
294+
window.addEventListener('resize', updatePosition);
295+
window.addEventListener('scroll', updatePosition, true);
296+
297+
return () => {
298+
window.removeEventListener('resize', updatePosition);
299+
window.removeEventListener('scroll', updatePosition, true);
300+
};
301+
}, [chevronRef]);
302+
303+
return createPortal(
304+
<div
305+
ref={menuRef}
306+
className="fixed z-50 w-[260px] rounded-2xl border bg-popover p-0 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95"
307+
style={{
308+
top: `${position.top}px`,
309+
left: `${position.left}px`,
310+
}}
311+
onMouseEnter={onMenuMouseEnter}
312+
onMouseLeave={onMenuMouseLeave}
313+
>
314+
{!isAuthenticated && (
315+
<div className="px-3 py-2 border-b">
316+
<div className="text-xs font-medium">Sign in to translate</div>
317+
<div className="mt-1 text-[11px] text-muted-foreground">
318+
Translate transcript and topics into 4 languages.
319+
</div>
320+
<Button
321+
size="sm"
322+
className="mt-2 h-7 text-xs w-full"
323+
onClick={(e) => {
324+
e.preventDefault();
325+
onRequestSignIn?.();
326+
}}
327+
>
328+
Sign in
329+
</Button>
330+
</div>
331+
)}
332+
<div className="px-2 py-1.5">
333+
<div className="relative">
334+
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
335+
<Input
336+
placeholder="Search"
337+
value={languageSearch}
338+
onChange={(e) => onLanguageSearchChange(e.target.value)}
339+
className="h-7 pl-7 text-xs"
340+
/>
341+
</div>
342+
</div>
343+
<div className="max-h-[300px] overflow-y-auto">
344+
{filteredLanguages.map((lang) => {
345+
const isOriginalLanguage = lang.code === 'en';
346+
const isTargetLanguage = lang.code === currentLanguageCode && selectedLanguage !== null;
347+
348+
return (
349+
<div
350+
key={lang.code}
351+
className={cn(
352+
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
353+
isOriginalLanguage && "cursor-default",
354+
!isAuthenticated && !isOriginalLanguage && "opacity-50"
355+
)}
356+
onClick={(e) => {
357+
if (isOriginalLanguage || (!isAuthenticated && !isOriginalLanguage)) {
358+
if (!isAuthenticated && !isOriginalLanguage) {
359+
e.preventDefault();
360+
onRequestSignIn?.();
361+
}
362+
return;
363+
}
364+
onLanguageSelect(lang.code);
365+
}}
366+
>
367+
<div className="flex items-center justify-between w-full">
368+
<div>
369+
<div className="font-medium">{lang.nativeName}</div>
370+
<div className="text-[10px] text-muted-foreground">{lang.name}</div>
371+
</div>
372+
{isOriginalLanguage ? (
373+
<CheckCircle2 className="w-4 h-4 text-muted-foreground/50" />
374+
) : isTargetLanguage ? (
375+
<CheckCircle2 className="w-4 h-4 text-foreground fill-background" />
376+
) : (
377+
<Circle className="w-4 h-4 text-muted-foreground/30" />
378+
)}
379+
</div>
380+
</div>
381+
);
382+
})}
383+
</div>
384+
</div>,
385+
document.body
386+
);
387+
}

0 commit comments

Comments
 (0)