Live video on the AT Protocol

Merge pull request #702 from streamplace/natb/dropdown-stuff-2

fix: clean up dropdown rendering a bit

authored by natalie and committed by GitHub 5fa32738 345bef7a

+756 -430
+4 -4
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 179 179 <View 180 180 style={[ 181 181 zero.flex.values[1], 182 - zero.layout.flex.row, 182 + isMobile ? zero.layout.flex.row : zero.layout.flex.column, 183 183 zero.layout.flex.spaceBetween, 184 184 zero.pr[4], 185 185 ]} 186 186 > 187 187 <Text>Quality</Text> 188 - <Text muted> 189 - ({quality}, {lowLatency ? "low latency" : "regular latency"} 190 - ) 188 + <Text muted size={isMobile ? "base" : "sm"}> 189 + {quality === "source" ? "Source" : quality},{" "} 190 + {lowLatency ? "Low Latency" : ""} 191 191 </Text> 192 192 </View> 193 193 </DropdownMenuSubTrigger>
+696
js/components/src/components/ui/dropdown.native.tsx
··· 1 + import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet"; 2 + import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu"; 3 + import { 4 + Check, 5 + CheckCircle, 6 + ChevronLeft, 7 + ChevronRight, 8 + Circle, 9 + } from "lucide-react-native"; 10 + import React, { 11 + createContext, 12 + forwardRef, 13 + ReactNode, 14 + startTransition, 15 + useContext, 16 + useRef, 17 + useState, 18 + } from "react"; 19 + import { Pressable, StyleSheet, useWindowDimensions, View } from "react-native"; 20 + import Animated, { 21 + runOnJS, 22 + useAnimatedStyle, 23 + useSharedValue, 24 + withTiming, 25 + } from "react-native-reanimated"; 26 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 27 + import { 28 + a, 29 + borderRadius, 30 + fontSize, 31 + gap, 32 + layout, 33 + ml, 34 + p, 35 + pb, 36 + pl, 37 + pr, 38 + pt, 39 + px, 40 + py, 41 + right, 42 + } from "../../lib/theme/atoms"; 43 + import { useTheme } from "../../ui"; 44 + import { 45 + objectFromObjects, 46 + TextContext as TextClassContext, 47 + } from "./primitives/text"; 48 + import { Text } from "./text"; 49 + 50 + // Navigation stack context for bottom sheet menus 51 + interface NavigationStackItem { 52 + key: string; 53 + title?: string; 54 + content: ReactNode | ((state: { pressed: boolean }) => ReactNode); 55 + } 56 + 57 + interface NavigationStackContextValue { 58 + stack: NavigationStackItem[]; 59 + push: (item: NavigationStackItem) => void; 60 + pop: () => void; 61 + isNested: boolean; 62 + } 63 + 64 + const NavigationStackContext = 65 + createContext<NavigationStackContextValue | null>(null); 66 + 67 + const useNavigationStack = () => { 68 + const context = useContext(NavigationStackContext); 69 + return context; 70 + }; 71 + 72 + // Context to capture submenu content for mobile navigation 73 + interface SubMenuContextValue { 74 + title?: string; 75 + renderContent: () => ReactNode; 76 + setRenderContent: (renderer: () => ReactNode) => void; 77 + setTitle: (title: string) => void; 78 + trigger: () => void; 79 + key: string | null; 80 + } 81 + 82 + const SubMenuContext = createContext<SubMenuContextValue | null>(null); 83 + 84 + // Context for RadioGroup on native 85 + interface RadioGroupContextValue { 86 + value?: string; 87 + onValueChange?: (value: string) => void; 88 + } 89 + 90 + const RadioGroupContext = createContext<RadioGroupContextValue | null>(null); 91 + 92 + export const DropdownMenu = DropdownMenuPrimitive.Root; 93 + export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 94 + export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 95 + 96 + export const DropdownMenuRadioGroup = forwardRef< 97 + React.ElementRef<typeof DropdownMenuPrimitive.RadioGroup>, 98 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> 99 + >(({ children, value, onValueChange, ...props }, ref) => { 100 + return ( 101 + <RadioGroupContext.Provider value={{ value, onValueChange }}> 102 + {children} 103 + </RadioGroupContext.Provider> 104 + ); 105 + }); 106 + 107 + export const DropdownMenuSub = forwardRef<any, any>( 108 + ({ children, ...props }, ref) => { 109 + const navStack = useNavigationStack(); 110 + const [subMenuTitle, setSubMenuTitle] = useState<string | undefined>(); 111 + const renderContentRef = useRef<(() => ReactNode) | null>(null); 112 + const [subMenuKey, setSubMenuKey] = useState<string | null>(null); 113 + 114 + const trigger = () => { 115 + if (renderContentRef.current && navStack) { 116 + const key = `submenu-${Date.now()}`; 117 + setSubMenuKey(key); 118 + navStack.push({ 119 + key, 120 + title: subMenuTitle, 121 + content: (props: any) => { 122 + const renderFn = renderContentRef.current; 123 + return renderFn ? renderFn() : null; 124 + }, 125 + }); 126 + } 127 + }; 128 + 129 + const setRenderContent = (renderer: () => ReactNode) => { 130 + renderContentRef.current = renderer; 131 + }; 132 + 133 + const contextValue = React.useMemo( 134 + () => ({ 135 + renderContent: () => renderContentRef.current?.(), 136 + setRenderContent, 137 + title: subMenuTitle, 138 + setTitle: setSubMenuTitle, 139 + trigger, 140 + key: subMenuKey, 141 + }), 142 + [subMenuTitle, subMenuKey, navStack], 143 + ); 144 + 145 + return ( 146 + <SubMenuContext.Provider value={contextValue}> 147 + {children} 148 + </SubMenuContext.Provider> 149 + ); 150 + }, 151 + ); 152 + 153 + export const DropdownMenuBottomSheet = forwardRef< 154 + any, 155 + DropdownMenuPrimitive.ContentProps & { 156 + overlayStyle?: any; 157 + portalHost?: string; 158 + } 159 + >(function DropdownMenuBottomSheet( 160 + { overlayStyle, portalHost, children, ...rest }, 161 + _ref, 162 + ) { 163 + const { onOpenChange } = DropdownMenuPrimitive.useRootContext(); 164 + const { zero: zt, theme } = useTheme(); 165 + const sheetRef = useRef<BottomSheet>(null); 166 + const { width } = useWindowDimensions(); 167 + const isWide = width >= 450; 168 + const sheetWidth = isWide ? 450 : width; 169 + const horizontalMargin = isWide ? (width - sheetWidth) / 2 : 0; 170 + 171 + const insets = useSafeAreaInsets(); 172 + 173 + const [stack, setStack] = useState<NavigationStackItem[]>([ 174 + { key: "root", content: children }, 175 + ]); 176 + 177 + React.useEffect(() => { 178 + setStack((prev) => { 179 + if (!Array.isArray(prev) || prev.length === 0) { 180 + return [{ key: "root", content: children }]; 181 + } 182 + const newStack = [...prev]; 183 + newStack[0] = { ...newStack[0], content: children }; 184 + return newStack; 185 + }); 186 + }, [children]); 187 + 188 + const slideAnim = useSharedValue(0); 189 + const fadeAnim = useSharedValue(1); 190 + 191 + const push = (item: NavigationStackItem) => { 192 + setStack((prev) => { 193 + if (!Array.isArray(prev)) 194 + return [{ key: "root", content: children }, item]; 195 + return [...prev, item]; 196 + }); 197 + 198 + slideAnim.value = 40; 199 + fadeAnim.value = 0; 200 + slideAnim.value = withTiming(0, { duration: 350 }); 201 + fadeAnim.value = withTiming(1, { duration: 350 }); 202 + }; 203 + 204 + const popStack = () => { 205 + startTransition(() => { 206 + setStack((prev) => { 207 + if (!Array.isArray(prev) || prev.length <= 1) { 208 + return [{ key: "root", content: children }]; 209 + } 210 + return prev.slice(0, -1); 211 + }); 212 + }); 213 + }; 214 + 215 + const resetAnimationValues = () => { 216 + setTimeout(() => { 217 + slideAnim.value = 0; 218 + fadeAnim.value = 1; 219 + }, 5); 220 + }; 221 + 222 + const pop = () => { 223 + if (stack.length <= 1) return; 224 + 225 + slideAnim.value = withTiming(40, { duration: 150 }); 226 + fadeAnim.value = withTiming(0, { duration: 150 }, (finished) => { 227 + if (finished) { 228 + runOnJS(popStack)(); 229 + runOnJS(resetAnimationValues)(); 230 + } 231 + }); 232 + }; 233 + 234 + const animatedStyle = useAnimatedStyle(() => ({ 235 + transform: [{ translateX: slideAnim.value }], 236 + opacity: fadeAnim.value, 237 + })); 238 + 239 + const headerAnimatedStyle = useAnimatedStyle(() => ({ 240 + opacity: fadeAnim.value, 241 + })); 242 + 243 + const currentLevel = stack[stack.length - 1]; 244 + const isNested = stack.length > 1; 245 + 246 + const onBackgroundTap = () => { 247 + if (sheetRef.current) sheetRef.current?.close(); 248 + 249 + setTimeout(() => { 250 + onOpenChange?.(false); 251 + }, 300); 252 + }; 253 + 254 + if (!currentLevel) { 255 + return null; 256 + } 257 + 258 + return ( 259 + <DropdownMenuPrimitive.Portal hostName={portalHost}> 260 + <NavigationStackContext.Provider value={{ stack, push, pop, isNested }}> 261 + <BottomSheet 262 + ref={sheetRef} 263 + enablePanDownToClose 264 + enableDynamicSizing 265 + detached={isWide} 266 + bottomInset={isWide ? 0 : 0} 267 + backdropComponent={({ style }) => ( 268 + <Pressable 269 + style={[style, StyleSheet.absoluteFill]} 270 + onPress={() => onBackgroundTap()} 271 + /> 272 + )} 273 + onClose={() => onOpenChange?.(false)} 274 + style={[ 275 + overlayStyle, 276 + StyleSheet.flatten(rest.style), 277 + isWide && { marginHorizontal: horizontalMargin }, 278 + ]} 279 + backgroundStyle={[zt.bg.popover, a.radius.all.md, a.shadows.md, p[1]]} 280 + handleIndicatorStyle={[ 281 + a.sizes.width[12], 282 + a.sizes.height[1], 283 + zt.bg.mutedForeground, 284 + ]} 285 + > 286 + {isNested && ( 287 + <Animated.View 288 + style={[ 289 + headerAnimatedStyle, 290 + a.layout.flex.row, 291 + a.layout.flex.alignCenter, 292 + px[4], 293 + pb[2], 294 + { 295 + borderBottomWidth: 1, 296 + borderBottomColor: theme.colors.border, 297 + }, 298 + ]} 299 + > 300 + <Pressable 301 + onPress={pop} 302 + style={[ 303 + a.layout.flex.row, 304 + a.layout.flex.alignCenter, 305 + gap.all[2], 306 + ]} 307 + hitSlop={80} 308 + > 309 + <ChevronLeft size={20} color={theme.colors.foreground} /> 310 + {currentLevel?.title ? ( 311 + <Text size="lg">{currentLevel.title}</Text> 312 + ) : null} 313 + </Pressable> 314 + </Animated.View> 315 + )} 316 + <Animated.View style={animatedStyle}> 317 + <BottomSheetScrollView 318 + style={[px[4]]} 319 + contentContainerStyle={{ 320 + paddingBottom: insets.bottom + 50, 321 + overflow: "hidden", 322 + }} 323 + > 324 + {stack.map((level, index) => { 325 + const isCurrent = index === stack.length - 1; 326 + return ( 327 + <View 328 + key={level.key} 329 + style={[{ display: isCurrent ? "flex" : "none" }]} 330 + > 331 + {typeof level.content === "function" 332 + ? level.content({ pressed: true }) 333 + : level.content} 334 + </View> 335 + ); 336 + })} 337 + </BottomSheetScrollView> 338 + </Animated.View> 339 + </BottomSheet> 340 + </NavigationStackContext.Provider> 341 + </DropdownMenuPrimitive.Portal> 342 + ); 343 + }); 344 + 345 + export const DropdownMenuSubTrigger = forwardRef< 346 + any, 347 + DropdownMenuPrimitive.SubTriggerProps & { 348 + inset?: boolean; 349 + subMenuTitle?: string; 350 + } & { 351 + ref?: React.RefObject<DropdownMenuPrimitive.SubTriggerRef>; 352 + className?: string; 353 + inset?: boolean; 354 + children?: React.ReactNode; 355 + } 356 + >(({ inset, children, subMenuTitle, ...props }, ref) => { 357 + const subMenuContext = useContext(SubMenuContext); 358 + const { icons } = useTheme(); 359 + 360 + React.useEffect(() => { 361 + if (subMenuContext && subMenuTitle) { 362 + subMenuContext.setTitle(subMenuTitle); 363 + } 364 + }, [subMenuContext, subMenuTitle]); 365 + 366 + return ( 367 + <Pressable 368 + onPress={() => { 369 + subMenuContext?.trigger(); 370 + }} 371 + {...props} 372 + > 373 + <View 374 + style={[ 375 + inset && gap[2], 376 + layout.flex.row, 377 + layout.flex.alignCenter, 378 + a.radius.all.sm, 379 + py[1], 380 + pl[2], 381 + pr[2], 382 + ]} 383 + > 384 + {typeof children === "function" ? ( 385 + children({ pressed: true }) 386 + ) : typeof children === "string" ? ( 387 + <Text>{children}</Text> 388 + ) : ( 389 + children 390 + )} 391 + <View style={[a.layout.position.absolute, a.position.right[1]]}> 392 + <ChevronRight size={18} color={icons.color.muted} /> 393 + </View> 394 + </View> 395 + </Pressable> 396 + ); 397 + }); 398 + 399 + export const DropdownMenuSubContent = forwardRef< 400 + any, 401 + DropdownMenuPrimitive.SubContentProps & { children?: ReactNode } 402 + >(({ children, ...props }, ref) => { 403 + const subMenuContext = useContext(SubMenuContext); 404 + const navStack = useNavigationStack(); 405 + const prevChildrenRef = useRef<ReactNode>(null); 406 + 407 + React.useEffect(() => { 408 + if (subMenuContext && navStack) { 409 + if (prevChildrenRef.current === children) { 410 + return; 411 + } 412 + 413 + prevChildrenRef.current = children; 414 + subMenuContext.setRenderContent(() => children); 415 + } 416 + }, [children, subMenuContext, navStack]); 417 + 418 + return null; 419 + }); 420 + 421 + export const DropdownMenuContent = forwardRef< 422 + any, 423 + DropdownMenuPrimitive.ContentProps & { 424 + overlayStyle?: any; 425 + portalHost?: string; 426 + } 427 + >(({ overlayStyle, portalHost, style, children, ...props }, ref) => { 428 + return ( 429 + <DropdownMenuBottomSheet {...props}>{children}</DropdownMenuBottomSheet> 430 + ); 431 + }); 432 + 433 + export const DropdownMenuContentWithoutPortal = forwardRef< 434 + any, 435 + DropdownMenuPrimitive.ContentProps & { 436 + overlayStyle?: any; 437 + maxHeightPercentage?: number; 438 + } 439 + >( 440 + ( 441 + { overlayStyle, maxHeightPercentage = 0.8, children, style, ...props }, 442 + ref, 443 + ) => { 444 + return ( 445 + <DropdownMenuBottomSheet {...props}>{children}</DropdownMenuBottomSheet> 446 + ); 447 + }, 448 + ); 449 + 450 + export const ResponsiveDropdownMenuContent = forwardRef< 451 + any, 452 + any & { onModeChange?: (isSheet: boolean) => void } 453 + >(({ children, onModeChange, ...props }, ref) => { 454 + React.useEffect(() => { 455 + onModeChange?.(true); 456 + }, [onModeChange]); 457 + 458 + return ( 459 + <DropdownMenuBottomSheet ref={ref} {...props}> 460 + {children} 461 + </DropdownMenuBottomSheet> 462 + ); 463 + }); 464 + 465 + export const DropdownMenuItem = forwardRef< 466 + any, 467 + DropdownMenuPrimitive.ItemProps & { inset?: boolean; disabled?: boolean } 468 + >(({ inset, disabled, style, children, ...props }, ref) => { 469 + const { theme } = useTheme(); 470 + return ( 471 + <Pressable {...props}> 472 + <TextClassContext.Provider 473 + value={objectFromObjects([ 474 + { color: theme.colors.popoverForeground }, 475 + a.fontSize.base, 476 + ])} 477 + > 478 + <View 479 + style={[ 480 + a.layout.flex.row, 481 + a.layout.flex.alignCenter, 482 + a.radius.all.sm, 483 + py[1], 484 + pl[2], 485 + pr[2], 486 + ]} 487 + > 488 + {typeof children === "function" ? ( 489 + children({ pressed: true }) 490 + ) : typeof children === "string" ? ( 491 + <Text style={[inset && gap[2], disabled && { opacity: 0.5 }]}> 492 + {children} 493 + </Text> 494 + ) : ( 495 + children 496 + )} 497 + </View> 498 + </TextClassContext.Provider> 499 + </Pressable> 500 + ); 501 + }); 502 + 503 + export const DropdownMenuCheckboxItem = forwardRef< 504 + any, 505 + DropdownMenuPrimitive.CheckboxItemProps & { 506 + ref?: React.RefObject<DropdownMenuPrimitive.CheckboxItemRef>; 507 + children?: React.ReactNode; 508 + } 509 + >(({ children, checked, ...props }, ref) => { 510 + const { theme } = useTheme(); 511 + 512 + return ( 513 + <Pressable 514 + onPress={(e) => { 515 + props.onCheckedChange?.(!checked); 516 + props.onPress?.(e); 517 + }} 518 + {...props} 519 + > 520 + <View 521 + style={[ 522 + a.layout.flex.row, 523 + a.layout.flex.alignCenter, 524 + a.radius.all.sm, 525 + py[1], 526 + pl[2], 527 + pr[2], 528 + pr[8], 529 + ]} 530 + > 531 + {children} 532 + <View style={[pl[1], layout.position.absolute, right[1]]}> 533 + {checked ? ( 534 + <CheckCircle 535 + size={14} 536 + strokeWidth={3} 537 + color={theme.colors.foreground} 538 + /> 539 + ) : ( 540 + <Circle 541 + size={14} 542 + strokeWidth={3} 543 + color={theme.colors.mutedForeground} 544 + /> 545 + )} 546 + </View> 547 + </View> 548 + </Pressable> 549 + ); 550 + }); 551 + 552 + export const DropdownMenuRadioItem = forwardRef< 553 + any, 554 + DropdownMenuPrimitive.RadioItemProps & { 555 + ref?: React.RefObject<DropdownMenuPrimitive.RadioItemRef>; 556 + children?: React.ReactNode; 557 + value?: string; 558 + } 559 + >(({ children, value, ...props }, ref) => { 560 + const { theme } = useTheme(); 561 + const radioGroupContext = useContext(RadioGroupContext); 562 + const isSelected = radioGroupContext?.value === value; 563 + 564 + return ( 565 + <Pressable 566 + onPress={(e) => { 567 + if (value && radioGroupContext?.onValueChange) { 568 + radioGroupContext.onValueChange(value); 569 + } 570 + props.onPress?.(e); 571 + }} 572 + {...props} 573 + > 574 + <View 575 + style={[ 576 + a.layout.flex.row, 577 + a.layout.flex.alignCenter, 578 + a.radius.all.sm, 579 + py[1], 580 + pl[2], 581 + pr[8], 582 + ]} 583 + > 584 + {children} 585 + {isSelected && ( 586 + <View style={[pl[1], layout.position.absolute, right[1]]}> 587 + <Check size={14} strokeWidth={3} color={theme.colors.foreground} /> 588 + </View> 589 + )} 590 + </View> 591 + </Pressable> 592 + ); 593 + }); 594 + 595 + export const DropdownMenuLabel = forwardRef< 596 + any, 597 + DropdownMenuPrimitive.LabelProps & { inset?: boolean } 598 + >(({ inset, ...props }, ref) => { 599 + const { theme } = useTheme(); 600 + return ( 601 + <Text 602 + ref={ref} 603 + style={ 604 + [ 605 + px[2], 606 + py[2], 607 + { color: theme.colors.textMuted }, 608 + a.fontSize.base, 609 + (inset && gap[2]) as any, 610 + ] as any 611 + } 612 + {...props} 613 + /> 614 + ); 615 + }); 616 + 617 + export const DropdownMenuSeparator = forwardRef< 618 + any, 619 + DropdownMenuPrimitive.SeparatorProps 620 + >((props, ref) => { 621 + const { theme } = useTheme(); 622 + return ( 623 + <View 624 + ref={ref} 625 + style={[ 626 + { 627 + borderBottomWidth: 1, 628 + borderBottomColor: theme.colors.border, 629 + marginVertical: -0.5, 630 + }, 631 + ]} 632 + {...props} 633 + /> 634 + ); 635 + }); 636 + 637 + export function DropdownMenuShortcut(props: any) { 638 + const { theme } = useTheme(); 639 + return ( 640 + <Text 641 + style={[ 642 + ml.auto, 643 + { color: theme.colors.textMuted }, 644 + a.fontSize.sm, 645 + a.letterSpacing.widest, 646 + ]} 647 + {...props} 648 + /> 649 + ); 650 + } 651 + 652 + export const DropdownMenuGroup = forwardRef< 653 + any, 654 + { inset?: boolean; title?: string; children: ReactNode } 655 + >((props, ref) => { 656 + const { theme } = useTheme(); 657 + const { inset, title, children, ...rest } = props; 658 + return ( 659 + <View style={[pt[2], inset && gap[2]]} ref={ref} {...rest}> 660 + {title && ( 661 + <Text style={[{ color: theme.colors.textMuted }, pb[1], pl[2]]}> 662 + {title} 663 + </Text> 664 + )} 665 + <View 666 + style={[ 667 + { backgroundColor: theme.colors.muted }, 668 + p[2], 669 + gap.all[1], 670 + { borderRadius: borderRadius.lg }, 671 + ]} 672 + > 673 + {children} 674 + </View> 675 + </View> 676 + ); 677 + }); 678 + 679 + export const DropdownMenuInfo = forwardRef<any, any>( 680 + ({ description, ...props }, ref) => { 681 + const { theme } = useTheme(); 682 + return ( 683 + <Text 684 + style={[ 685 + { color: theme.colors.textMuted }, 686 + pt[1], 687 + pl[2], 688 + pb[2], 689 + fontSize.sm, 690 + ]} 691 + > 692 + {description} 693 + </Text> 694 + ); 695 + }, 696 + );
+56 -426
js/components/src/components/ui/dropdown.tsx
··· 1 - import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet"; 2 1 import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu"; 3 2 import { 4 3 Check, 5 - CheckCircle, 6 4 ChevronDown, 7 - ChevronLeft, 8 5 ChevronRight, 9 6 ChevronUp, 10 - Circle, 11 7 } from "lucide-react-native"; 12 - import React, { 13 - createContext, 14 - forwardRef, 15 - ReactNode, 16 - startTransition, 17 - useContext, 18 - useRef, 19 - useState, 20 - } from "react"; 8 + import React, { forwardRef, ReactNode } from "react"; 21 9 import { 22 10 Platform, 23 - Pressable, 24 11 ScrollView, 25 12 StyleSheet, 26 13 useWindowDimensions, 27 14 View, 28 15 } from "react-native"; 29 - import Animated, { 30 - runOnJS, 31 - useAnimatedStyle, 32 - useSharedValue, 33 - withTiming, 34 - } from "react-native-reanimated"; 35 - import { useSafeAreaInsets } from "react-native-safe-area-context"; 36 16 import { 37 17 a, 38 18 borderRadius, ··· 57 37 } from "./primitives/text"; 58 38 import { Text } from "./text"; 59 39 60 - // Navigation stack context for bottom sheet menus 61 - interface NavigationStackItem { 62 - key: string; 63 - title?: string; 64 - content: ReactNode | ((state: { pressed: boolean }) => ReactNode); 65 - } 66 - 67 - interface NavigationStackContextValue { 68 - stack: NavigationStackItem[]; 69 - push: (item: NavigationStackItem) => void; 70 - pop: () => void; 71 - isNested: boolean; 72 - } 73 - 74 - const NavigationStackContext = 75 - createContext<NavigationStackContextValue | null>(null); 76 - 77 - const useNavigationStack = () => { 78 - const context = useContext(NavigationStackContext); 79 - return context; 80 - }; 81 - 82 - // Context to capture submenu content for mobile navigation 83 - interface SubMenuContextValue { 84 - title?: string; 85 - renderContent: () => ReactNode; 86 - setRenderContent: (renderer: () => ReactNode) => void; 87 - setTitle: (title: string) => void; 88 - trigger: () => void; 89 - key: string | null; 90 - } 91 - 92 - const SubMenuContext = createContext<SubMenuContextValue | null>(null); 93 - 94 40 export const DropdownMenu = DropdownMenuPrimitive.Root; 95 41 export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 96 42 export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 97 - export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 43 + 44 + export const DropdownMenuRadioGroup = forwardRef< 45 + React.ElementRef<typeof DropdownMenuPrimitive.RadioGroup>, 46 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> 47 + >(({ children, ...props }, ref) => { 48 + return ( 49 + <DropdownMenuPrimitive.RadioGroup ref={ref} {...props}> 50 + {children} 51 + </DropdownMenuPrimitive.RadioGroup> 52 + ); 53 + }); 98 54 99 - // Custom DropdownMenuSub that works with mobile navigation 100 55 export const DropdownMenuSub = forwardRef<any, any>( 101 56 ({ children, ...props }, ref) => { 102 - const navStack = useNavigationStack(); 103 - const [subMenuTitle, setSubMenuTitle] = useState<string | undefined>(); 104 - const renderContentRef = useRef<(() => ReactNode) | null>(null); 105 - const [subMenuKey, setSubMenuKey] = useState<string | null>(null); 106 - 107 - // If we're in a mobile navigation stack, use custom context 108 - if (navStack) { 109 - const trigger = () => { 110 - if (renderContentRef.current) { 111 - const key = `submenu-${Date.now()}`; 112 - setSubMenuKey(key); 113 - navStack.push({ 114 - key, 115 - title: subMenuTitle, 116 - // Store a function that always reads the latest content from the ref 117 - content: (props: any) => { 118 - const renderFn = renderContentRef.current; 119 - return renderFn ? renderFn() : null; 120 - }, 121 - }); 122 - } 123 - }; 124 - 125 - const setRenderContent = (renderer: () => ReactNode) => { 126 - renderContentRef.current = renderer; 127 - }; 128 - 129 - const contextValue = React.useMemo( 130 - () => ({ 131 - renderContent: () => renderContentRef.current?.(), 132 - setRenderContent, 133 - title: subMenuTitle, 134 - setTitle: setSubMenuTitle, 135 - trigger, 136 - key: subMenuKey, 137 - }), 138 - [subMenuTitle, subMenuKey], 139 - ); 140 - 141 - return ( 142 - <SubMenuContext.Provider value={contextValue}> 143 - {children} 144 - </SubMenuContext.Provider> 145 - ); 146 - } 147 - 148 - // Web - use primitive 149 57 return ( 150 58 <DropdownMenuPrimitive.Sub ref={ref} {...props}> 151 59 {children} ··· 154 62 }, 155 63 ); 156 64 157 - export const DropdownMenuBottomSheet = forwardRef< 158 - any, 159 - DropdownMenuPrimitive.ContentProps & { 160 - overlayStyle?: any; 161 - portalHost?: string; 162 - } 163 - >(function DropdownMenuBottomSheet( 164 - { overlayStyle, portalHost, children, ...rest }, 165 - _ref, 166 - ) { 167 - // Use the primitives' context to know if open 168 - const { onOpenChange } = DropdownMenuPrimitive.useRootContext(); 169 - const { zero: zt, theme } = useTheme(); 170 - const sheetRef = useRef<BottomSheet>(null); 171 - const { width } = useWindowDimensions(); 172 - const isWide = Platform.OS !== "web" && width >= 800; 173 - const sheetWidth = isWide ? 450 : width; 174 - const horizontalMargin = isWide ? (width - sheetWidth) / 2 : 0; 175 - 176 - const insets = useSafeAreaInsets(); 177 - 178 - // Navigation stack state 179 - const [stack, setStack] = useState<NavigationStackItem[]>([ 180 - { key: "root", content: children }, 181 - ]); 182 - 183 - // Update root content when children changes 184 - React.useEffect(() => { 185 - setStack((prev) => { 186 - if (!Array.isArray(prev) || prev.length === 0) { 187 - return [{ key: "root", content: children }]; 188 - } 189 - // Update the root item content 190 - const newStack = [...prev]; 191 - newStack[0] = { ...newStack[0], content: children }; 192 - return newStack; 193 - }); 194 - }, [children]); 195 - 196 - const slideAnim = useSharedValue(0); 197 - const fadeAnim = useSharedValue(1); 198 - 199 - const push = (item: NavigationStackItem) => { 200 - // First, update the stack 201 - setStack((prev) => { 202 - if (!Array.isArray(prev)) 203 - return [{ key: "root", content: children }, item]; 204 - return [...prev, item]; 205 - }); 206 - 207 - // Then animate from right to center with fade 208 - slideAnim.value = 40; 209 - fadeAnim.value = 0; 210 - slideAnim.value = withTiming(0, { duration: 350 }); 211 - fadeAnim.value = withTiming(1, { duration: 350 }); 212 - }; 213 - 214 - const popStack = () => { 215 - startTransition(() => { 216 - setStack((prev) => { 217 - if (!Array.isArray(prev) || prev.length <= 1) { 218 - return [{ key: "root", content: children }]; 219 - } 220 - return prev.slice(0, -1); 221 - }); 222 - }); 223 - }; 224 - 225 - const resetAnimationValues = () => { 226 - setTimeout(() => { 227 - slideAnim.value = 0; 228 - fadeAnim.value = 1; 229 - }, 5); 230 - }; 231 - 232 - const pop = () => { 233 - if (stack.length <= 1) return; 234 - 235 - // Animate out to the right with fade 236 - slideAnim.value = withTiming(40, { duration: 150 }); 237 - fadeAnim.value = withTiming(0, { duration: 150 }, (finished) => { 238 - if (finished) { 239 - // Update stack first with startTransition for smoother render 240 - runOnJS(popStack)(); 241 - 242 - // Then reset animation position after a brief delay to ensure component has unmounted 243 - runOnJS(resetAnimationValues)(); 244 - } 245 - }); 246 - }; 247 - 248 - const animatedStyle = useAnimatedStyle(() => ({ 249 - transform: [{ translateX: slideAnim.value }], 250 - opacity: fadeAnim.value, 251 - })); 252 - 253 - const headerAnimatedStyle = useAnimatedStyle(() => ({ 254 - opacity: fadeAnim.value, 255 - })); 256 - 257 - const currentLevel = stack[stack.length - 1]; 258 - const isNested = stack.length > 1; 259 - 260 - const onBackgroundTap = () => { 261 - if (sheetRef.current) sheetRef.current?.close(); 262 - 263 - setTimeout(() => { 264 - onOpenChange?.(false); 265 - }, 300); 266 - }; 267 - 268 - // Safety check - if no current level, don't render 269 - if (!currentLevel) { 270 - return null; 271 - } 272 - 273 - return ( 274 - <DropdownMenuPrimitive.Portal hostName={portalHost}> 275 - <NavigationStackContext.Provider value={{ stack, push, pop, isNested }}> 276 - <BottomSheet 277 - ref={sheetRef} 278 - enablePanDownToClose 279 - enableDynamicSizing 280 - detached={isWide} 281 - bottomInset={isWide ? 0 : 0} 282 - backdropComponent={({ style }) => ( 283 - <Pressable 284 - style={[style, StyleSheet.absoluteFill]} 285 - onPress={() => onBackgroundTap()} 286 - /> 287 - )} 288 - onClose={() => onOpenChange?.(false)} 289 - style={[ 290 - overlayStyle, 291 - StyleSheet.flatten(rest.style), 292 - isWide && { marginHorizontal: horizontalMargin }, 293 - ]} 294 - backgroundStyle={[zt.bg.popover, a.radius.all.md, a.shadows.md, p[1]]} 295 - handleIndicatorStyle={[ 296 - a.sizes.width[12], 297 - a.sizes.height[1], 298 - zt.bg.mutedForeground, 299 - ]} 300 - > 301 - {isNested && ( 302 - <Animated.View 303 - style={[ 304 - headerAnimatedStyle, 305 - a.layout.flex.row, 306 - a.layout.flex.alignCenter, 307 - px[4], 308 - pb[2], 309 - { 310 - borderBottomWidth: 1, 311 - borderBottomColor: theme.colors.border, 312 - }, 313 - ]} 314 - > 315 - <Pressable 316 - onPress={pop} 317 - style={[ 318 - a.layout.flex.row, 319 - a.layout.flex.alignCenter, 320 - gap.all[2], 321 - ]} 322 - hitSlop={80} 323 - > 324 - <ChevronLeft size={20} color={theme.colors.foreground} /> 325 - {currentLevel?.title ? ( 326 - <Text size="lg">{currentLevel.title}</Text> 327 - ) : null} 328 - </Pressable> 329 - </Animated.View> 330 - )} 331 - <Animated.View style={animatedStyle}> 332 - <BottomSheetScrollView 333 - style={[px[4]]} 334 - contentContainerStyle={{ 335 - paddingBottom: insets.bottom + 50, 336 - overflow: "hidden", 337 - }} 338 - > 339 - {/* Render all stack levels to keep components mounted, but hide non-current ones */} 340 - {stack.map((level, index) => { 341 - const isCurrent = index === stack.length - 1; 342 - return ( 343 - <View 344 - key={level.key} 345 - style={[{ display: isCurrent ? "flex" : "none" }]} 346 - > 347 - {typeof level.content === "function" 348 - ? level.content({ pressed: true }) 349 - : level.content} 350 - </View> 351 - ); 352 - })} 353 - </BottomSheetScrollView> 354 - </Animated.View> 355 - </BottomSheet> 356 - </NavigationStackContext.Provider> 357 - </DropdownMenuPrimitive.Portal> 358 - ); 359 - }); 360 - 361 65 export const DropdownMenuSubTrigger = forwardRef< 362 66 any, 363 67 DropdownMenuPrimitive.SubTriggerProps & { ··· 370 74 children?: React.ReactNode; 371 75 } 372 76 >(({ inset, children, subMenuTitle, ...props }, ref) => { 373 - const navStack = useNavigationStack(); 374 - const subMenuContext = useContext(SubMenuContext); 375 - const { icons, theme } = useTheme(); 376 - 377 - // Set the title in the submenu context if provided 378 - React.useEffect(() => { 379 - if (subMenuContext && subMenuTitle) { 380 - subMenuContext.setTitle(subMenuTitle); 381 - } 382 - }, [subMenuContext, subMenuTitle]); 383 - 384 - // If we're in a navigation stack (mobile bottom sheet), handle differently 385 - if (navStack && subMenuContext) { 386 - return ( 387 - <Pressable 388 - onPress={() => { 389 - subMenuContext.trigger(); 390 - }} 391 - {...props} 392 - > 393 - <View 394 - style={[ 395 - inset && gap[2], 396 - layout.flex.row, 397 - layout.flex.alignCenter, 398 - a.radius.all.sm, 399 - py[1], 400 - pl[2], 401 - pr[2], 402 - ]} 403 - > 404 - {typeof children === "function" ? ( 405 - children({ pressed: true }) 406 - ) : typeof children === "string" ? ( 407 - <Text>{children}</Text> 408 - ) : ( 409 - children 410 - )} 411 - <View style={[a.layout.position.absolute, a.position.right[1]]}> 412 - <ChevronRight size={18} color={icons.color.muted} /> 413 - </View> 414 - </View> 415 - </Pressable> 416 - ); 417 - } 418 - 419 - // Web behavior - use primitive 77 + const { icons } = useTheme(); 420 78 const { open } = DropdownMenuPrimitive.useSubContext(); 421 79 const Icon = 422 80 Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown; 81 + 423 82 return ( 424 83 <TextClassContext.Provider 425 84 value={objectFromObjects([ ··· 453 112 DropdownMenuPrimitive.SubContentProps & { children?: ReactNode } 454 113 >(({ children, ...props }, ref) => { 455 114 const { zero: zt } = useTheme(); 456 - const subMenuContext = useContext(SubMenuContext); 457 - const navStack = useNavigationStack(); 458 - const prevChildrenRef = useRef<ReactNode>(null); 459 115 460 - // Register a render function that will be called fresh each time 461 - React.useEffect(() => { 462 - if (subMenuContext && navStack) { 463 - // Only update if children reference actually changed 464 - if (prevChildrenRef.current === children) { 465 - return; 466 - } 467 - 468 - prevChildrenRef.current = children; 469 - 470 - // Pass a function that returns the current children 471 - subMenuContext.setRenderContent(() => children); 472 - 473 - // Force a stack update to trigger rerender with the actual children 474 - if (subMenuContext.key) { 475 - // Store the children directly so React can handle updates 476 - //navStack.updateContent(subMenuContext.key, children); 477 - } 478 - } 479 - }, [children, subMenuContext, navStack]); 480 - 481 - // On mobile, don't render the subcontent here - it'll be rendered in the nav stack 482 - // But keep the component mounted so effects run when children change 483 - if (navStack && subMenuContext) { 484 - // Component stays mounted to track prop changes, but renders nothing 485 - return null; 486 - } 487 - 488 - // Web - use primitive 489 116 return ( 490 117 <DropdownMenuPrimitive.SubContent 491 118 ref={ref} 492 119 style={[ 493 120 a.zIndex[50], 494 - a.sizes.minWidth[32], 121 + a.sizes.minWidth[64], 122 + a.sizes.maxWidth[64], 495 123 a.overflow.hidden, 496 124 a.radius.all.md, 497 125 a.borders.width.thin, ··· 517 145 >(({ overlayStyle, portalHost, style, children, ...props }, ref) => { 518 146 const { zero: zt } = useTheme(); 519 147 const { height } = useWindowDimensions(); 520 - const maxHeight = height * 0.8; 148 + const maxHeight = height * 0.9; 521 149 522 150 return ( 523 151 <DropdownMenuPrimitive.Portal hostName={portalHost}> ··· 532 160 style={ 533 161 [ 534 162 a.zIndex[50], 535 - a.sizes.minWidth[32], 163 + a.sizes.minWidth[64], 536 164 a.sizes.maxWidth[64], 165 + { maxHeight: maxHeight }, 537 166 a.overflow.hidden, 538 167 a.radius.all.md, 539 168 a.borders.width.thin, ··· 546 175 } 547 176 {...props} 548 177 > 549 - <ScrollView style={{ maxHeight }} showsVerticalScrollIndicator={true}> 178 + <ScrollView showsVerticalScrollIndicator={false}> 550 179 {typeof children === "function" 551 180 ? children({ pressed: false }) 552 181 : children} ··· 565 194 } 566 195 >( 567 196 ( 568 - { overlayStyle, maxHeightPercentage = 0.8, children, style, ...props }, 197 + { overlayStyle, maxHeightPercentage = 0.9, children, style, ...props }, 569 198 ref, 570 199 ) => { 571 200 const { theme } = useTheme(); ··· 584 213 style={ 585 214 [ 586 215 { zIndex: 999999 }, 587 - a.sizes.minWidth[32], 216 + a.sizes.minWidth[64], 588 217 a.sizes.maxWidth[64], 218 + { maxHeight: maxHeight }, 589 219 a.radius.all.md, 590 220 a.borders.width.thin, 591 221 { borderColor: theme.colors.border }, ··· 597 227 } 598 228 {...props} 599 229 > 600 - <ScrollView style={{ maxHeight }} showsVerticalScrollIndicator={true}> 230 + <ScrollView showsVerticalScrollIndicator={false}> 601 231 {typeof children === "function" 602 232 ? children({ pressed: false }) 603 233 : children} ··· 608 238 }, 609 239 ); 610 240 611 - /// Responsive Dropdown Menu Content. On mobile this will render a *bottom sheet* that is **portaled to the root of the app**. 612 - /// Prefer passing scoped content in as **otherwise it may crash the app**. 613 - export const ResponsiveDropdownMenuContent = forwardRef<any, any>( 614 - ({ children, ...props }, ref) => { 615 - const { width } = useWindowDimensions(); 241 + export const ResponsiveDropdownMenuContent = forwardRef< 242 + any, 243 + any & { onModeChange?: (isSheet: boolean) => void } 244 + >(({ children, onModeChange, ...props }, ref) => { 245 + const { width } = useWindowDimensions(); 246 + 247 + const isBottomSheet = 248 + Platform.OS !== "web" || (Platform.OS === "web" && width <= 980); 616 249 617 - // On web, you might want to always use the normal dropdown 618 - const isBottomSheet = Platform.OS !== "web"; 250 + React.useEffect(() => { 251 + onModeChange?.(isBottomSheet); 252 + }, [isBottomSheet, onModeChange]); 619 253 620 - if (isBottomSheet) { 621 - return ( 622 - <DropdownMenuBottomSheet ref={ref} {...props}> 623 - {children} 624 - </DropdownMenuBottomSheet> 625 - ); 626 - } 254 + if (isBottomSheet) { 627 255 return ( 628 256 <DropdownMenuContent align="start" ref={ref} {...props}> 629 257 {children} 630 258 </DropdownMenuContent> 631 259 ); 632 - }, 633 - ); 260 + } 261 + return ( 262 + <DropdownMenuContent ref={ref} {...props}> 263 + {children} 264 + </DropdownMenuContent> 265 + ); 266 + }); 634 267 635 268 export const DropdownMenuItem = forwardRef< 636 269 any, ··· 638 271 >(({ inset, disabled, style, children, ...props }, ref) => { 639 272 const { theme } = useTheme(); 640 273 return ( 641 - <Pressable {...props}> 274 + <DropdownMenuPrimitive.Item ref={ref} {...props}> 642 275 <TextClassContext.Provider 643 276 value={objectFromObjects([ 644 277 { color: theme.colors.popoverForeground }, ··· 666 299 )} 667 300 </View> 668 301 </TextClassContext.Provider> 669 - </Pressable> 302 + </DropdownMenuPrimitive.Item> 670 303 ); 671 304 }); 672 305 ··· 678 311 } 679 312 >(({ children, checked, ...props }, ref) => { 680 313 const { theme } = useTheme(); 314 + 681 315 return ( 682 316 <DropdownMenuPrimitive.CheckboxItem 683 317 ref={ref} ··· 698 332 > 699 333 {children} 700 334 <View style={[pl[1], layout.position.absolute, right[1]]}> 701 - {checked ? ( 702 - <CheckCircle 703 - size={14} 704 - strokeWidth={3} 705 - color={theme.colors.foreground} 706 - /> 707 - ) : ( 708 - <Circle 709 - size={14} 710 - strokeWidth={3} 711 - color={theme.colors.mutedForeground} 712 - /> 713 - )} 335 + <DropdownMenuPrimitive.ItemIndicator> 336 + <Check size={14} strokeWidth={3} color={theme.colors.foreground} /> 337 + </DropdownMenuPrimitive.ItemIndicator> 714 338 </View> 715 339 </View> 716 340 </DropdownMenuPrimitive.CheckboxItem> ··· 722 346 DropdownMenuPrimitive.RadioItemProps & { 723 347 ref?: React.RefObject<DropdownMenuPrimitive.RadioItemRef>; 724 348 children?: React.ReactNode; 349 + value?: string; 725 350 } 726 - >(({ children, ...props }, ref) => { 351 + >(({ children, value, ...props }, ref) => { 727 352 const { theme } = useTheme(); 353 + 728 354 return ( 729 355 <DropdownMenuPrimitive.RadioItem 730 356 ref={ref} 731 357 closeOnPress={props.closeOnPress || false} 358 + value={value} 732 359 {...props} 733 360 > 734 361 <View ··· 854 481 ); 855 482 }, 856 483 ); 484 + 485 + // Re-export DropdownMenuBottomSheet for compatibility with native 486 + export const DropdownMenuBottomSheet = DropdownMenuContent;