@streamplace/components 0.8.8 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,23 @@
1
- import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
1
+ import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet";
2
2
  import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu";
3
3
  import {
4
4
  Check,
5
5
  CheckCircle,
6
6
  ChevronDown,
7
+ ChevronLeft,
7
8
  ChevronRight,
8
9
  ChevronUp,
9
10
  Circle,
10
11
  } from "lucide-react-native";
11
- import React, { forwardRef, ReactNode, useRef } from "react";
12
+ import React, {
13
+ createContext,
14
+ forwardRef,
15
+ ReactNode,
16
+ startTransition,
17
+ useContext,
18
+ useRef,
19
+ useState,
20
+ } from "react";
12
21
  import {
13
22
  Platform,
14
23
  Pressable,
@@ -17,7 +26,13 @@ import {
17
26
  useWindowDimensions,
18
27
  View,
19
28
  } from "react-native";
20
- import { zero } from "../..";
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";
21
36
  import {
22
37
  a,
23
38
  borderRadius,
@@ -42,12 +57,103 @@ import {
42
57
  } from "./primitives/text";
43
58
  import { Text } from "./text";
44
59
 
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
+
45
94
  export const DropdownMenu = DropdownMenuPrimitive.Root;
46
95
  export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
47
96
  export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
48
- export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
49
97
  export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
50
98
 
99
+ // Custom DropdownMenuSub that works with mobile navigation
100
+ export const DropdownMenuSub = forwardRef<any, any>(
101
+ ({ 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
+ return (
150
+ <DropdownMenuPrimitive.Sub ref={ref} {...props}>
151
+ {children}
152
+ </DropdownMenuPrimitive.Sub>
153
+ );
154
+ },
155
+ );
156
+
51
157
  export const DropdownMenuBottomSheet = forwardRef<
52
158
  any,
53
159
  DropdownMenuPrimitive.ContentProps & {
@@ -59,53 +165,259 @@ export const DropdownMenuBottomSheet = forwardRef<
59
165
  _ref,
60
166
  ) {
61
167
  // Use the primitives' context to know if open
62
- const { open, onOpenChange } = DropdownMenuPrimitive.useRootContext();
63
- const { zero: zt } = useTheme();
168
+ const { onOpenChange } = DropdownMenuPrimitive.useRootContext();
169
+ const { zero: zt, theme } = useTheme();
64
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
+ }
65
272
 
66
273
  return (
67
274
  <DropdownMenuPrimitive.Portal hostName={portalHost}>
68
- <BottomSheet
69
- ref={sheetRef}
70
- enablePanDownToClose
71
- enableDynamicSizing
72
- enableContentPanningGesture={false}
73
- backdropComponent={({ style }) => (
74
- <Pressable
75
- style={[style, StyleSheet.absoluteFill]}
76
- onPress={() => onOpenChange?.(false)}
77
- />
78
- )}
79
- onClose={() => onOpenChange?.(false)}
80
- style={[overlayStyle, StyleSheet.flatten(rest.style)]}
81
- backgroundStyle={[zt.bg.popover, a.radius.all.md, a.shadows.md, p[1]]}
82
- handleIndicatorStyle={[
83
- a.sizes.width[12],
84
- a.sizes.height[1],
85
- zt.bg.mutedForeground,
86
- ]}
87
- >
88
- <BottomSheetView style={[px[4]]}>
89
- {typeof children === "function"
90
- ? children({ pressed: true })
91
- : children}
92
- </BottomSheetView>
93
- </BottomSheet>
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>
94
357
  </DropdownMenuPrimitive.Portal>
95
358
  );
96
359
  });
97
360
 
98
361
  export const DropdownMenuSubTrigger = forwardRef<
99
362
  any,
100
- DropdownMenuPrimitive.SubTriggerProps & { inset?: boolean } & {
363
+ DropdownMenuPrimitive.SubTriggerProps & {
364
+ inset?: boolean;
365
+ subMenuTitle?: string;
366
+ } & {
101
367
  ref?: React.RefObject<DropdownMenuPrimitive.SubTriggerRef>;
102
368
  className?: string;
103
369
  inset?: boolean;
104
370
  children?: React.ReactNode;
105
371
  }
106
- >(({ inset, children, ...props }, ref) => {
372
+ >(({ 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
107
420
  const { open } = DropdownMenuPrimitive.useSubContext();
108
- const { icons } = useTheme();
109
421
  const Icon =
110
422
  Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown;
111
423
  return (
@@ -138,9 +450,42 @@ export const DropdownMenuSubTrigger = forwardRef<
138
450
 
139
451
  export const DropdownMenuSubContent = forwardRef<
140
452
  any,
141
- DropdownMenuPrimitive.SubContentProps
142
- >((props, ref) => {
453
+ DropdownMenuPrimitive.SubContentProps & { children?: ReactNode }
454
+ >(({ children, ...props }, ref) => {
143
455
  const { zero: zt } = useTheme();
456
+ const subMenuContext = useContext(SubMenuContext);
457
+ const navStack = useNavigationStack();
458
+ const prevChildrenRef = useRef<ReactNode>(null);
459
+
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
144
489
  return (
145
490
  <DropdownMenuPrimitive.SubContent
146
491
  ref={ref}
@@ -157,7 +502,9 @@ export const DropdownMenuSubContent = forwardRef<
157
502
  a.shadows.md,
158
503
  ]}
159
504
  {...props}
160
- />
505
+ >
506
+ {children}
507
+ </DropdownMenuPrimitive.SubContent>
161
508
  );
162
509
  });
163
510
 
@@ -169,6 +516,9 @@ export const DropdownMenuContent = forwardRef<
169
516
  }
170
517
  >(({ overlayStyle, portalHost, style, children, ...props }, ref) => {
171
518
  const { zero: zt } = useTheme();
519
+ const { height } = useWindowDimensions();
520
+ const maxHeight = height * 0.8;
521
+
172
522
  return (
173
523
  <DropdownMenuPrimitive.Portal hostName={portalHost}>
174
524
  <DropdownMenuPrimitive.Overlay
@@ -196,7 +546,7 @@ export const DropdownMenuContent = forwardRef<
196
546
  }
197
547
  {...props}
198
548
  >
199
- <ScrollView showsVerticalScrollIndicator={true}>
549
+ <ScrollView style={{ maxHeight }} showsVerticalScrollIndicator={true}>
200
550
  {typeof children === "function"
201
551
  ? children({ pressed: false })
202
552
  : children}
@@ -265,12 +615,12 @@ export const ResponsiveDropdownMenuContent = forwardRef<any, any>(
265
615
  const { width } = useWindowDimensions();
266
616
 
267
617
  // On web, you might want to always use the normal dropdown
268
- const isBottomSheet = Platform.OS !== "web" && width < 800;
618
+ const isBottomSheet = Platform.OS !== "web";
269
619
 
270
620
  if (isBottomSheet) {
271
621
  return (
272
622
  <DropdownMenuBottomSheet ref={ref} {...props}>
273
- <ScrollView style={[zero.pb[12]]}>{children}</ScrollView>
623
+ {children}
274
624
  </DropdownMenuBottomSheet>
275
625
  );
276
626
  }