@gv-tech/ui-native 2.15.2 → 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.15.2",
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
 
@@ -207,3 +215,11 @@ export { ThemeToggle } from './theme-toggle';
207
215
 
208
216
  // Toaster
209
217
  export { Toaster } from './toaster';
218
+
219
+ // Table Of Contents
220
+ export {
221
+ TableOfContents,
222
+ TableOfContentsContent,
223
+ TableOfContentsHeading,
224
+ TableOfContentsList,
225
+ } from './table-of-contents';
package/src/lib/utils.ts CHANGED
@@ -4,3 +4,13 @@ import { twMerge } from 'tailwind-merge';
4
4
  export function cn(...inputs: ClassValue[]) {
5
5
  return twMerge(clsx(inputs));
6
6
  }
7
+
8
+ export function slugify(text: string): string {
9
+ return text
10
+ .toString()
11
+ .toLowerCase()
12
+ .trim()
13
+ .replace(/\s+/g, '-') // Replace spaces with -
14
+ .replace(/[^\w-]+/g, '') // Remove all non-word chars
15
+ .replace(/--+/g, '-'); // Replace multiple - with single -
16
+ }
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 };