@gv-tech/ui-native 2.16.0 → 2.17.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gv-tech/ui-native",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "React Native implementations of the GV Tech design system components",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.ts CHANGED
@@ -112,7 +112,7 @@ export { NavigationMenu } from './navigation-menu';
112
112
  export { Pagination } from './pagination';
113
113
 
114
114
  // Popover
115
- export { Popover } from './popover';
115
+ export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './popover';
116
116
 
117
117
  // Progress
118
118
  export { Progress } from './progress';
@@ -144,6 +144,14 @@ export {
144
144
  SelectValue,
145
145
  } from './select';
146
146
 
147
+ // Scroll To Top
148
+ export { ScrollToTop } from './scroll-to-top';
149
+ export type { ScrollToTopHandle, ScrollToTopProps } from './scroll-to-top';
150
+
151
+ // Support FAB
152
+ export { SupportFab } from './support-fab';
153
+ export type { SupportFabProps } from './support-fab';
154
+
147
155
  // Separator
148
156
  export { Separator } from './separator';
149
157
 
package/src/popover.tsx CHANGED
@@ -1,9 +1,85 @@
1
- import { Text, View } from 'react-native';
1
+ import {
2
+ PopoverAnchorBaseProps,
3
+ PopoverBaseProps,
4
+ PopoverContentBaseProps,
5
+ PopoverTriggerBaseProps,
6
+ } from '@gv-tech/ui-core';
7
+ import * as React from 'react';
8
+ import { Modal, Pressable, View } from 'react-native';
9
+
10
+ import { cn } from './lib/utils';
11
+
12
+ const PopoverContext = React.createContext<{
13
+ open: boolean;
14
+ setOpen: (open: boolean) => void;
15
+ }>({
16
+ open: false,
17
+ setOpen: () => {},
18
+ });
19
+
20
+ const Popover = React.forwardRef<
21
+ React.ElementRef<typeof View>,
22
+ React.ComponentPropsWithoutRef<typeof View> & PopoverBaseProps
23
+ >(({ children, open, onOpenChange, ...props }, ref) => {
24
+ const [internalOpen, setInternalOpen] = React.useState(false);
25
+ const isControlled = open !== undefined;
26
+ const isOpen = isControlled ? open : internalOpen;
27
+ const setOpen = isControlled ? onOpenChange || (() => {}) : setInternalOpen;
28
+
29
+ return (
30
+ <PopoverContext.Provider value={{ open: isOpen, setOpen }}>
31
+ <View ref={ref} {...props}>
32
+ {children}
33
+ </View>
34
+ </PopoverContext.Provider>
35
+ );
36
+ });
37
+ Popover.displayName = 'Popover';
38
+
39
+ const PopoverTrigger = React.forwardRef<
40
+ React.ElementRef<typeof Pressable>,
41
+ React.ComponentPropsWithoutRef<typeof Pressable> & PopoverTriggerBaseProps
42
+ >(({ children, ...props }, ref) => {
43
+ const { setOpen } = React.useContext(PopoverContext);
2
44
 
3
- export const Popover = () => {
4
45
  return (
5
- <View>
6
- <Text>popover is not yet implemented for React Native</Text>
7
- </View>
46
+ <Pressable ref={ref} onPress={() => setOpen(true)} {...props}>
47
+ {children}
48
+ </Pressable>
8
49
  );
9
- };
50
+ });
51
+ PopoverTrigger.displayName = 'PopoverTrigger';
52
+
53
+ const PopoverAnchor = React.forwardRef<
54
+ React.ElementRef<typeof View>,
55
+ React.ComponentPropsWithoutRef<typeof View> & PopoverAnchorBaseProps
56
+ >(({ ...props }, ref) => <View ref={ref} {...props} />);
57
+ PopoverAnchor.displayName = 'PopoverAnchor';
58
+
59
+ const PopoverContent = React.forwardRef<
60
+ React.ElementRef<typeof View>,
61
+ React.ComponentPropsWithoutRef<typeof View> & PopoverContentBaseProps
62
+ >(({ className, children, ...props }, ref) => {
63
+ const { open, setOpen } = React.useContext(PopoverContext);
64
+
65
+ return (
66
+ <Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
67
+ <Pressable className="flex-1" onPress={() => setOpen(false)}>
68
+ <View className="flex-1 items-center justify-center bg-black/50">
69
+ <Pressable onPress={() => {}}>
70
+ <View
71
+ ref={ref}
72
+ className={cn('bg-popover border-border mx-4 w-full max-w-sm rounded-md border p-4 shadow-lg', className)}
73
+ {...props}
74
+ >
75
+ {children}
76
+ </View>
77
+ </Pressable>
78
+ </View>
79
+ </Pressable>
80
+ </Modal>
81
+ );
82
+ });
83
+ PopoverContent.displayName = 'PopoverContent';
84
+
85
+ export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
package/src/progress.tsx CHANGED
@@ -1,9 +1,23 @@
1
- import { Text, View } from 'react-native';
1
+ import { ProgressBaseProps } from '@gv-tech/ui-core';
2
+ import * as React from 'react';
3
+ import { View } from 'react-native';
2
4
 
3
- export const Progress = () => {
4
- return (
5
- <View>
6
- <Text>progress is not yet implemented for React Native</Text>
7
- </View>
8
- );
9
- };
5
+ import { cn } from './lib/utils';
6
+
7
+ const Progress = React.forwardRef<
8
+ React.ElementRef<typeof View>,
9
+ React.ComponentPropsWithoutRef<typeof View> & ProgressBaseProps
10
+ >(({ className, value, ...props }, ref) => (
11
+ <View
12
+ ref={ref}
13
+ className={cn('bg-muted relative h-2 w-full overflow-hidden rounded-full', className)}
14
+ accessibilityRole="progressbar"
15
+ role="progressbar"
16
+ {...props}
17
+ >
18
+ <View className="bg-primary h-full rounded-full" style={{ width: `${value || 0}%` }} />
19
+ </View>
20
+ ));
21
+ Progress.displayName = 'Progress';
22
+
23
+ export { Progress };
@@ -0,0 +1,186 @@
1
+ 'use client';
2
+
3
+ import { ArrowUp } from 'lucide-react-native';
4
+ import * as React from 'react';
5
+ import {
6
+ AccessibilityInfo,
7
+ Animated,
8
+ type FlatList,
9
+ type NativeScrollEvent,
10
+ type NativeSyntheticEvent,
11
+ Platform,
12
+ ScrollView,
13
+ View,
14
+ } from 'react-native';
15
+
16
+ import type { ScrollToTopBaseProps } from '@gv-tech/ui-core';
17
+ import { Button } from './button';
18
+ import { cn } from './lib/utils';
19
+
20
+ export interface ScrollToTopProps extends ScrollToTopBaseProps {
21
+ /**
22
+ * For Native: The scroll target is typically a ref to a ScrollView or FlatList.
23
+ * This is required unless you manually call the scroll handler.
24
+ */
25
+ scrollRef?: React.RefObject<ScrollView | FlatList>;
26
+
27
+ /**
28
+ * Custom duration specifically for Native animations (opacity/transform).
29
+ * @default 300
30
+ */
31
+ animationDuration?: number;
32
+ }
33
+
34
+ /**
35
+ * GV Tech Animated Scroll To Top (Native)
36
+ *
37
+ * A floating action button that appears when scrolling down and allows the user
38
+ * to quickly return to the top of a ScrollView, FlatList, or SectionList.
39
+ *
40
+ * Reuses the internal GV Tech Button primitive for consistent styling.
41
+ */
42
+ export interface ScrollToTopHandle {
43
+ handleScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
44
+ scrollToTop: () => void;
45
+ }
46
+
47
+ export const ScrollToTop = React.forwardRef<ScrollToTopHandle, ScrollToTopProps>(
48
+ (
49
+ {
50
+ threshold = 240,
51
+ exitDuration = 450,
52
+ behavior, // Native uses 'smooth' or just scrolls
53
+ label = 'Scroll to top',
54
+ className,
55
+ scrollRef,
56
+ animationDuration = 300,
57
+ ...props
58
+ },
59
+ ref,
60
+ ) => {
61
+ const [isVisible, setIsVisible] = React.useState(false);
62
+ const [isExiting, setIsExiting] = React.useState(false);
63
+
64
+ // Animation states
65
+ const opacity = React.useRef(new Animated.Value(0)).current;
66
+ const translateY = React.useRef(new Animated.Value(20)).current;
67
+
68
+ const animateIn = () => {
69
+ Animated.parallel([
70
+ Animated.timing(opacity, {
71
+ toValue: 1,
72
+ duration: animationDuration,
73
+ useNativeDriver: true,
74
+ }),
75
+ Animated.timing(translateY, {
76
+ toValue: 0,
77
+ duration: animationDuration,
78
+ useNativeDriver: true,
79
+ }),
80
+ ]).start();
81
+ };
82
+
83
+ const animateOut = () => {
84
+ Animated.parallel([
85
+ Animated.timing(opacity, {
86
+ toValue: 0,
87
+ duration: animationDuration,
88
+ useNativeDriver: true,
89
+ }),
90
+ Animated.timing(translateY, {
91
+ toValue: 20,
92
+ duration: animationDuration,
93
+ useNativeDriver: true,
94
+ }),
95
+ ]).start(() => {
96
+ if (!isExiting) {
97
+ setIsVisible(false);
98
+ }
99
+ });
100
+ };
101
+
102
+ const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
103
+ const offset = event.nativeEvent.contentOffset.y;
104
+
105
+ if (isExiting) {
106
+ if (offset <= threshold) {
107
+ setIsVisible(false);
108
+ setIsExiting(false);
109
+ }
110
+ return;
111
+ }
112
+
113
+ if (offset > threshold && !isVisible) {
114
+ setIsVisible(true);
115
+ animateIn();
116
+ } else if (offset <= threshold && isVisible) {
117
+ animateOut();
118
+ }
119
+ };
120
+
121
+ const scrollToTop = () => {
122
+ setIsExiting(true);
123
+ animateOut();
124
+
125
+ // Delay the actual scroll to let exit animation start
126
+ setTimeout(() => {
127
+ if (scrollRef?.current) {
128
+ // Detect if it's a FlatList or ScrollView and handle types safely
129
+ const current = scrollRef.current;
130
+ if ('scrollToOffset' in current && typeof current.scrollToOffset === 'function') {
131
+ current.scrollToOffset({ offset: 0, animated: behavior !== 'auto' });
132
+ } else if ('scrollTo' in current && typeof current.scrollTo === 'function') {
133
+ current.scrollTo({ y: 0, animated: behavior !== 'auto' });
134
+ }
135
+ }
136
+
137
+ // Announcements for accessibility
138
+ if (Platform.OS === 'ios' || Platform.OS === 'android') {
139
+ AccessibilityInfo.announceForAccessibility('Scrolled to top');
140
+ }
141
+ }, exitDuration);
142
+ };
143
+
144
+ // Expose handleScroll so parents can pipe their scroll events to this component
145
+ React.useImperativeHandle(ref, () => ({
146
+ handleScroll,
147
+ scrollToTop,
148
+ }));
149
+
150
+ if (!isVisible && !isExiting) {
151
+ return null;
152
+ }
153
+
154
+ return (
155
+ <View
156
+ pointerEvents="box-none"
157
+ style={{
158
+ position: 'absolute',
159
+ bottom: 24,
160
+ right: 24,
161
+ zIndex: 50,
162
+ }}
163
+ >
164
+ <Animated.View
165
+ style={{
166
+ opacity,
167
+ transform: [{ translateY }],
168
+ }}
169
+ >
170
+ <Button
171
+ variant="outline"
172
+ size="icon"
173
+ className={cn('bg-background/80 h-12 w-12 rounded-full border-2 shadow-lg', className)}
174
+ onPress={scrollToTop}
175
+ aria-label={label}
176
+ {...props}
177
+ >
178
+ <ArrowUp size={24} className="text-foreground" />
179
+ </Button>
180
+ </Animated.View>
181
+ </View>
182
+ );
183
+ },
184
+ );
185
+
186
+ ScrollToTop.displayName = 'ScrollToTop';
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import { Coffee, ExternalLink } from 'lucide-react-native';
4
+ import * as React from 'react';
5
+ import { Linking, View } from 'react-native';
6
+
7
+ import { Button } from './button';
8
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog';
9
+ import { cn } from './lib/utils';
10
+ import { Text } from './text';
11
+
12
+ const normalizeBaseUrl = (url: string) => {
13
+ const trimmed = url.trim();
14
+ return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
15
+ };
16
+
17
+ const sanitizeCreator = (creatorId: string) => creatorId.trim().replace(/^@+/, '');
18
+
19
+ const buildPageUrl = (supportUrl: string, creatorId: string) => {
20
+ const base = normalizeBaseUrl(supportUrl);
21
+ const creator = encodeURIComponent(sanitizeCreator(creatorId));
22
+ return `${base}/${creator}`;
23
+ };
24
+
25
+ export interface SupportFabProps extends Omit<React.ComponentPropsWithoutRef<typeof Button>, 'onPress'> {
26
+ supportUrl?: string;
27
+ creatorId: string;
28
+ title?: string;
29
+ description?: string;
30
+ open?: boolean;
31
+ defaultOpen?: boolean;
32
+ onOpenChange?: (open: boolean) => void;
33
+ positionClassName?: string;
34
+ buttonClassName?: string;
35
+ panelClassName?: string;
36
+ }
37
+
38
+ export function SupportFab({
39
+ supportUrl = 'https://www.buymeacoffee.com',
40
+ creatorId,
41
+ title = 'Buy me a coffee',
42
+ description = 'Support the project directly from this panel.',
43
+ open,
44
+ defaultOpen = false,
45
+ onOpenChange,
46
+ positionClassName,
47
+ buttonClassName,
48
+ panelClassName,
49
+ className,
50
+ ...buttonProps
51
+ }: SupportFabProps) {
52
+ const isControlled = open !== undefined;
53
+ const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
54
+ const isOpen = isControlled ? open : internalOpen;
55
+
56
+ const setOpen = React.useCallback(
57
+ (nextOpen: boolean) => {
58
+ if (!isControlled) {
59
+ setInternalOpen(nextOpen);
60
+ }
61
+ onOpenChange?.(nextOpen);
62
+ },
63
+ [isControlled, onOpenChange],
64
+ );
65
+
66
+ const pageUrl = React.useMemo(() => buildPageUrl(supportUrl, creatorId), [creatorId, supportUrl]);
67
+
68
+ const handleExternalLink = React.useCallback(async () => {
69
+ try {
70
+ await Linking.openURL(pageUrl);
71
+ } catch (error) {
72
+ console.warn('Failed to open external link:', error);
73
+ }
74
+ }, [pageUrl]);
75
+
76
+ const trigger = (
77
+ <View
78
+ style={{
79
+ position: 'absolute',
80
+ bottom: 24,
81
+ right: 24,
82
+ zIndex: 50,
83
+ }}
84
+ className={positionClassName}
85
+ >
86
+ <Button
87
+ variant="outline"
88
+ size="icon"
89
+ className={cn(
90
+ 'h-14 w-14 rounded-full border border-black/15 bg-[#ffdd00] shadow-lg',
91
+ buttonClassName,
92
+ className,
93
+ )}
94
+ onPress={() => setOpen(true)}
95
+ aria-label="Support this project"
96
+ {...buttonProps}
97
+ >
98
+ <Coffee size={24} className="text-black" />
99
+ </Button>
100
+ </View>
101
+ );
102
+
103
+ return (
104
+ <>
105
+ {trigger}
106
+ <Dialog open={isOpen} onOpenChange={setOpen}>
107
+ <DialogContent className={cn('max-w-sm', panelClassName)}>
108
+ <DialogHeader>
109
+ <DialogTitle>{title}</DialogTitle>
110
+ <DialogDescription>{description}</DialogDescription>
111
+ </DialogHeader>
112
+
113
+ <View className="flex flex-col gap-4">
114
+ <View className="overflow-hidden rounded-md border">
115
+ <View className="bg-muted flex h-48 items-center justify-center">
116
+ <Text className="text-muted-foreground text-center text-sm">
117
+ Embedded support form would appear here on web.{'\n'}
118
+ On mobile, this opens the external support page.
119
+ </Text>
120
+ </View>
121
+ </View>
122
+
123
+ <View className="text-muted-foreground flex items-center justify-between gap-2 text-xs">
124
+ <Text className="text-muted-foreground flex-1 text-xs">
125
+ If the embedded checkout is unavailable, open the support page directly.
126
+ </Text>
127
+ <Button
128
+ variant="outline"
129
+ size="sm"
130
+ onPress={handleExternalLink}
131
+ className="flex flex-row items-center gap-1"
132
+ >
133
+ <Text className="text-xs">Open Buy Me a Coffee</Text>
134
+ <ExternalLink size={12} className="text-muted-foreground" />
135
+ </Button>
136
+ </View>
137
+ </View>
138
+ </DialogContent>
139
+ </Dialog>
140
+ </>
141
+ );
142
+ }
143
+
144
+ export { buildPageUrl };
@@ -3,6 +3,7 @@ import {
3
3
  LayoutChangeEvent,
4
4
  NativeScrollEvent,
5
5
  NativeSyntheticEvent,
6
+ Platform,
6
7
  Text as RNText,
7
8
  ScrollView,
8
9
  TouchableOpacity,
@@ -221,9 +222,11 @@ export function TableOfContentsContent({ children, className }: TableOfContentsC
221
222
  <ScrollView
222
223
  ref={scrollViewRef}
223
224
  onScroll={onScroll}
224
- scrollEventThrottle={16}
225
225
  className={cn('flex-1', className)}
226
- contentContainerStyle={{ padding: 16 }}
226
+ {...(Platform.OS !== 'web' && {
227
+ scrollEventThrottle: 16,
228
+ contentContainerStyle: { padding: 16 },
229
+ })}
227
230
  >
228
231
  {children}
229
232
  </ScrollView>
package/src/tabs.tsx CHANGED
@@ -5,10 +5,13 @@ import * as React from 'react';
5
5
  import { cn } from './lib/utils';
6
6
  import { TextClassContext } from './text';
7
7
 
8
- const Tabs = TabsPrimitive.Root;
8
+ const Tabs = React.forwardRef<React.ElementRef<typeof TabsPrimitive.Root>, TabsProps>((props, ref) => (
9
+ <TabsPrimitive.Root ref={ref} {...props} />
10
+ ));
11
+ Tabs.displayName = 'Tabs';
9
12
 
10
- export interface TabsProps
11
- extends Omit<React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>, 'onValueChange' | 'value'>, TabsBaseProps {}
13
+ export type TabsProps = Omit<React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>, 'onValueChange' | 'value'> &
14
+ Omit<TabsBaseProps, 'value'> & { value: string; onValueChange: (value: string) => void };
12
15
  export interface TabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>, TabsListBaseProps {}
13
16
  export interface TabsTriggerProps
14
17
  extends
package/src/tooltip.tsx CHANGED
@@ -19,28 +19,22 @@ const TooltipContent: React.ForwardRefExoticComponent<
19
19
  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
20
20
  portalHost?: string;
21
21
  }
22
- >(
23
- (
24
- { className, portalHost, ...props },
25
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
26
- _ref,
27
- ) => (
28
- <TooltipPrimitive.Portal hostName={portalHost}>
29
- <TooltipPrimitive.Overlay style={Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined}>
30
- <Animated.View
31
- entering={FadeIn}
32
- exiting={FadeOut}
33
- className={cn(
34
- 'border-border bg-popover web:animate-in web:fade-in-0 web:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 shadow-md',
35
- className,
36
- )}
37
- >
38
- <Text className="text-popover-foreground native:text-base text-sm">{props.children}</Text>
39
- </Animated.View>
40
- </TooltipPrimitive.Overlay>
41
- </TooltipPrimitive.Portal>
42
- ),
43
- );
22
+ >(({ className, portalHost, ...props }, _ref) => (
23
+ <TooltipPrimitive.Portal hostName={portalHost}>
24
+ <TooltipPrimitive.Overlay style={Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined}>
25
+ <Animated.View
26
+ entering={FadeIn}
27
+ exiting={FadeOut}
28
+ className={cn(
29
+ 'border-border bg-popover web:animate-in web:fade-in-0 web:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 shadow-md',
30
+ className,
31
+ )}
32
+ >
33
+ <Text className="text-popover-foreground native:text-base text-sm">{props.children}</Text>
34
+ </Animated.View>
35
+ </TooltipPrimitive.Overlay>
36
+ </TooltipPrimitive.Portal>
37
+ ));
44
38
  TooltipContent.displayName = TooltipPrimitive.Content?.displayName || 'TooltipContent';
45
39
 
46
40
  const TooltipProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;