Skip to content

Commit 25671e8

Browse files
committed
feat: implement dropdown component for improved UI interactions
- Removed dependency on @radix-ui/react-dropdown-menu and integrated a custom Dropdown component for better control and styling. - Updated VideoCaptionsMenu and VideoQualityMenu to utilize the new Dropdown component, enhancing the user experience with consistent styling and functionality. - Simplified button styles in the Button component for a cleaner look.
1 parent b91aba7 commit 25671e8

File tree

5 files changed

+325
-40
lines changed

5 files changed

+325
-40
lines changed

packages/core/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"react-dom": ">=18"
3434
},
3535
"dependencies": {
36-
"@radix-ui/react-dropdown-menu": "^2.0.6",
3736
"@radix-ui/react-slider": "^1.1.2",
3837
"@radix-ui/react-slot": "^1.1.0",
3938
"clsx": "^2.0.0",

packages/core/src/ui/button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function Button({
2121
ref={ref}
2222
onClick={handleClick}
2323
className={cn(
24-
"rv-flex rv-justify-center rv-items-center rv-transition-transform hover:rv-scale-110 active:rv-scale-90",
24+
"rv-flex rv-justify-center rv-items-center",
2525
{
2626
"rv-w-8 rv-h-8": size === "sm",
2727
"rv-w-10 rv-h-10": size === "md",

packages/core/src/ui/dropdown.tsx

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { cn } from "@/lib/utils";
2+
import React, {
3+
createContext,
4+
useContext,
5+
useState,
6+
useRef,
7+
useEffect,
8+
useCallback,
9+
} from "react";
10+
import { createPortal } from "react-dom";
11+
import { AnimatePresence, motion } from "motion/react";
12+
import { usePrefersReducedMotion } from "@/lib/hooks/use-prefers-reduced-motion";
13+
14+
type DropdownContextValue = {
15+
isOpen: boolean;
16+
setIsOpen: (open: boolean) => void;
17+
triggerRef: React.RefObject<HTMLElement>;
18+
};
19+
20+
const DropdownContext = createContext<DropdownContextValue | null>(null);
21+
22+
function useDropdownContext() {
23+
const context = useContext(DropdownContext);
24+
if (!context) {
25+
throw new Error("Dropdown components must be used within Dropdown.Root");
26+
}
27+
return context;
28+
}
29+
30+
type DropdownRootProps = {
31+
children: React.ReactNode;
32+
modal?: boolean;
33+
};
34+
35+
function DropdownRoot({ children }: DropdownRootProps) {
36+
const [isOpen, setIsOpen] = useState(false);
37+
const triggerRef = useRef<HTMLElement>(null);
38+
39+
return (
40+
<DropdownContext.Provider value={{ isOpen, setIsOpen, triggerRef }}>
41+
{children}
42+
</DropdownContext.Provider>
43+
);
44+
}
45+
46+
type DropdownTriggerProps = {
47+
children: React.ReactElement;
48+
asChild?: boolean;
49+
};
50+
51+
function DropdownTrigger({ children, asChild }: DropdownTriggerProps) {
52+
const { isOpen, setIsOpen, triggerRef } = useDropdownContext();
53+
54+
const handleClick = useCallback(() => {
55+
setIsOpen(!isOpen);
56+
}, [isOpen, setIsOpen]);
57+
58+
if (asChild && React.isValidElement(children)) {
59+
return React.cloneElement(children, {
60+
ref: triggerRef,
61+
onClick: (e: React.MouseEvent) => {
62+
handleClick();
63+
children.props.onClick?.(e);
64+
},
65+
"aria-expanded": isOpen,
66+
"aria-haspopup": "true",
67+
} as any);
68+
}
69+
70+
return (
71+
<button
72+
ref={triggerRef as React.RefObject<HTMLButtonElement>}
73+
onClick={handleClick}
74+
aria-expanded={isOpen}
75+
aria-haspopup="true"
76+
>
77+
{children}
78+
</button>
79+
);
80+
}
81+
82+
type DropdownPortalProps = {
83+
children: React.ReactNode;
84+
};
85+
86+
function DropdownPortal({ children }: DropdownPortalProps) {
87+
const { isOpen } = useDropdownContext();
88+
89+
return createPortal(
90+
<AnimatePresence>{isOpen && children}</AnimatePresence>,
91+
document.body
92+
);
93+
}
94+
95+
type DropdownContentProps = {
96+
children: React.ReactNode;
97+
className?: string;
98+
sideOffset?: number;
99+
align?: "start" | "center" | "end";
100+
};
101+
102+
function DropdownContent({
103+
children,
104+
className,
105+
sideOffset = 5,
106+
align = "start",
107+
}: DropdownContentProps) {
108+
const { isOpen, setIsOpen, triggerRef } = useDropdownContext();
109+
const contentRef = useRef<HTMLDivElement>(null);
110+
const [position, setPosition] = useState({ top: 0, left: 0 });
111+
const prefersReducedMotion = usePrefersReducedMotion();
112+
113+
// Calculate position - open to top
114+
useEffect(() => {
115+
if (!isOpen || !triggerRef.current) return;
116+
117+
const update = () => {
118+
if (!triggerRef.current || !contentRef.current) return;
119+
120+
const triggerRect = triggerRef.current.getBoundingClientRect();
121+
const contentRect = contentRef.current.getBoundingClientRect();
122+
123+
let left = triggerRect.left;
124+
if (align === "end") {
125+
left = triggerRect.right - contentRect.width;
126+
} else if (align === "center") {
127+
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2;
128+
}
129+
130+
setPosition({
131+
top: triggerRect.top - contentRect.height - sideOffset,
132+
left,
133+
});
134+
};
135+
136+
// Initial update
137+
const timer = setTimeout(update, 0);
138+
139+
window.addEventListener("resize", update);
140+
window.addEventListener("scroll", update, true);
141+
142+
return () => {
143+
clearTimeout(timer);
144+
window.removeEventListener("resize", update);
145+
window.removeEventListener("scroll", update, true);
146+
};
147+
}, [isOpen, sideOffset, align, triggerRef]);
148+
149+
// Handle click outside
150+
useEffect(() => {
151+
if (!isOpen) return;
152+
153+
const handleClickOutside = (event: MouseEvent) => {
154+
if (
155+
contentRef.current &&
156+
!contentRef.current.contains(event.target as Node) &&
157+
triggerRef.current &&
158+
!triggerRef.current.contains(event.target as Node)
159+
) {
160+
setIsOpen(false);
161+
}
162+
};
163+
164+
const timer = setTimeout(() => {
165+
document.addEventListener("mousedown", handleClickOutside);
166+
}, 0);
167+
168+
return () => {
169+
clearTimeout(timer);
170+
document.removeEventListener("mousedown", handleClickOutside);
171+
};
172+
}, [isOpen, setIsOpen, contentRef, triggerRef]);
173+
174+
// Handle escape key
175+
useEffect(() => {
176+
if (!isOpen) return;
177+
178+
const handleEscape = (event: KeyboardEvent) => {
179+
if (event.key === "Escape") {
180+
setIsOpen(false);
181+
}
182+
};
183+
184+
document.addEventListener("keydown", handleEscape);
185+
return () => {
186+
document.removeEventListener("keydown", handleEscape);
187+
};
188+
}, [isOpen, setIsOpen]);
189+
190+
return (
191+
<motion.div
192+
ref={contentRef}
193+
initial={{ opacity: 0, y: 10, scale: 0.95 }}
194+
animate={{ opacity: 1, y: 0, scale: 1 }}
195+
exit={{ opacity: 0, y: 10, scale: 0.95 }}
196+
transition={{
197+
duration: prefersReducedMotion ? 0 : 0.2,
198+
ease: "easeOut",
199+
}}
200+
className={className}
201+
style={{
202+
position: "fixed",
203+
top: `${position.top}px`,
204+
left: `${position.left}px`,
205+
zIndex: 100,
206+
}}
207+
role="menu"
208+
>
209+
{children}
210+
</motion.div>
211+
);
212+
}
213+
214+
type DropdownItemProps = {
215+
children: React.ReactNode;
216+
className?: string;
217+
onSelect?: () => void;
218+
role?: string;
219+
"aria-checked"?: boolean;
220+
};
221+
222+
function DropdownItem({
223+
children,
224+
className,
225+
onSelect,
226+
role,
227+
...props
228+
}: DropdownItemProps) {
229+
const { setIsOpen } = useDropdownContext();
230+
231+
const handleClick = () => {
232+
onSelect?.();
233+
setIsOpen(false);
234+
};
235+
236+
const handleKeyDown = (e: React.KeyboardEvent) => {
237+
if (e.key === "Enter" || e.key === " ") {
238+
e.preventDefault();
239+
handleClick();
240+
}
241+
};
242+
243+
return (
244+
<div
245+
className={className}
246+
onClick={handleClick}
247+
onKeyDown={handleKeyDown}
248+
role={role || "menuitem"}
249+
tabIndex={0}
250+
{...props}
251+
>
252+
{children}
253+
</div>
254+
);
255+
}
256+
257+
type DropdownSeparatorProps = {
258+
className?: string;
259+
};
260+
261+
function DropdownSeparator({ className }: DropdownSeparatorProps) {
262+
return <div className={className} role="separator" />;
263+
}
264+
265+
// Shared menu styles
266+
export const menuStyles = {
267+
container: "rv-bg-black/20 rv-backdrop-blur-2xl rv-rounded-2xl rv-p-1 rv-shadow-2xl rv-border rv-border-white/5",
268+
separator: "rv-h-[1px] rv-bg-white/10 rv-my-1",
269+
item: {
270+
base: "rv-text-xs rv-text-white/80 rv-px-2.5 rv-py-1.5 rv-rounded-xl rv-cursor-pointer rv-outline-none rv-transition-colors",
271+
hover: "hover:rv-bg-white/10 hover:rv-text-white focus:rv-bg-white/10",
272+
active: "rv-bg-white/10 rv-text-white rv-font-medium",
273+
},
274+
} as const;
275+
276+
// Backward compatibility
277+
export const menuItemStyles = menuStyles.item;
278+
279+
export const Dropdown = {
280+
Root: DropdownRoot,
281+
Trigger: DropdownTrigger,
282+
Portal: DropdownPortal,
283+
Content: DropdownContent,
284+
Item: DropdownItem,
285+
Separator: DropdownSeparator,
286+
};

packages/core/src/video/controls/captions-menu.tsx

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { cn } from "@/lib/utils";
2-
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
2+
import { Dropdown, menuStyles } from "../../ui/dropdown";
33
import Button from "../../ui/button";
44
import { useVideoContext } from "../context";
55

@@ -42,8 +42,8 @@ export function VideoCaptionsMenu({
4242
}
4343

4444
return (
45-
<DropdownMenu.Root modal={false}>
46-
<DropdownMenu.Trigger asChild>
45+
<Dropdown.Root modal={false}>
46+
<Dropdown.Trigger asChild>
4747
<Button
4848
size={size}
4949
radius={radius}
@@ -54,20 +54,20 @@ export function VideoCaptionsMenu({
5454
<span className="rv-text-sm rv-font-semibold">CC</span>
5555
)}
5656
</Button>
57-
</DropdownMenu.Trigger>
57+
</Dropdown.Trigger>
5858

59-
<DropdownMenu.Portal>
60-
<DropdownMenu.Content
61-
className="rv-min-w-[160px] rv-bg-black/90 rv-backdrop-blur-sm rv-rounded-lg rv-p-1 rv-shadow-lg rv-border rv-border-white/10 rv-z-[100]"
62-
sideOffset={5}
59+
<Dropdown.Portal>
60+
<Dropdown.Content
61+
className={cn("rv-min-w-[140px]", menuStyles.container)}
62+
sideOffset={8}
6363
align="end"
6464
>
6565
{/* Off option */}
66-
<DropdownMenu.Item
66+
<Dropdown.Item
6767
className={cn(
68-
"rv-text-sm rv-text-white rv-px-3 rv-py-2 rv-rounded rv-cursor-pointer rv-outline-none",
69-
"hover:rv-bg-white/10 focus:rv-bg-white/10",
70-
activeTrackIndex === null && "rv-bg-white/20"
68+
menuStyles.item.base,
69+
menuStyles.item.hover,
70+
activeTrackIndex === null && menuStyles.item.active
7171
)}
7272
onSelect={() => setActiveTrackIndex(null)}
7373
role="menuitemradio"
@@ -79,18 +79,18 @@ export function VideoCaptionsMenu({
7979
</span>
8080
)}
8181
Off
82-
</DropdownMenu.Item>
82+
</Dropdown.Item>
8383

84-
<DropdownMenu.Separator className="rv-h-[1px] rv-bg-white/10 rv-my-1" />
84+
<Dropdown.Separator className={menuStyles.separator} />
8585

8686
{/* Track options */}
8787
{tracks.map((track, index) => (
88-
<DropdownMenu.Item
88+
<Dropdown.Item
8989
key={index}
9090
className={cn(
91-
"rv-text-sm rv-text-white rv-px-3 rv-py-2 rv-rounded rv-cursor-pointer rv-outline-none",
92-
"hover:rv-bg-white/10 focus:rv-bg-white/10",
93-
activeTrackIndex === index && "rv-bg-white/20"
91+
menuStyles.item.base,
92+
menuStyles.item.hover,
93+
activeTrackIndex === index && menuStyles.item.active
9494
)}
9595
onSelect={() => setActiveTrackIndex(index)}
9696
role="menuitemradio"
@@ -102,11 +102,11 @@ export function VideoCaptionsMenu({
102102
</span>
103103
)}
104104
{track.label || `Track ${index + 1}`}
105-
</DropdownMenu.Item>
105+
</Dropdown.Item>
106106
))}
107-
</DropdownMenu.Content>
108-
</DropdownMenu.Portal>
109-
</DropdownMenu.Root>
107+
</Dropdown.Content>
108+
</Dropdown.Portal>
109+
</Dropdown.Root>
110110
);
111111
}
112112

0 commit comments

Comments
 (0)