@metacells/mcellui-mcp-server 0.1.1 → 0.1.2
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/dist/index.js +8 -2
- package/package.json +5 -3
- package/registry/registry.json +717 -0
- package/registry/ui/accordion.tsx +416 -0
- package/registry/ui/action-sheet.tsx +396 -0
- package/registry/ui/alert-dialog.tsx +355 -0
- package/registry/ui/avatar-stack.tsx +278 -0
- package/registry/ui/avatar.tsx +116 -0
- package/registry/ui/badge.tsx +125 -0
- package/registry/ui/button.tsx +240 -0
- package/registry/ui/card.tsx +675 -0
- package/registry/ui/carousel.tsx +431 -0
- package/registry/ui/checkbox.tsx +252 -0
- package/registry/ui/chip.tsx +271 -0
- package/registry/ui/column.tsx +133 -0
- package/registry/ui/datetime-picker.tsx +578 -0
- package/registry/ui/dialog.tsx +292 -0
- package/registry/ui/fab.tsx +225 -0
- package/registry/ui/form.tsx +323 -0
- package/registry/ui/horizontal-list.tsx +200 -0
- package/registry/ui/icon-button.tsx +244 -0
- package/registry/ui/image-gallery.tsx +455 -0
- package/registry/ui/image.tsx +283 -0
- package/registry/ui/input.tsx +242 -0
- package/registry/ui/label.tsx +99 -0
- package/registry/ui/list.tsx +519 -0
- package/registry/ui/progress.tsx +168 -0
- package/registry/ui/pull-to-refresh.tsx +231 -0
- package/registry/ui/radio-group.tsx +294 -0
- package/registry/ui/rating.tsx +311 -0
- package/registry/ui/row.tsx +136 -0
- package/registry/ui/screen.tsx +153 -0
- package/registry/ui/search-input.tsx +281 -0
- package/registry/ui/section-header.tsx +258 -0
- package/registry/ui/segmented-control.tsx +229 -0
- package/registry/ui/select.tsx +311 -0
- package/registry/ui/separator.tsx +74 -0
- package/registry/ui/sheet.tsx +362 -0
- package/registry/ui/skeleton.tsx +156 -0
- package/registry/ui/slider.tsx +307 -0
- package/registry/ui/spinner.tsx +100 -0
- package/registry/ui/stepper.tsx +314 -0
- package/registry/ui/stories.tsx +463 -0
- package/registry/ui/swipeable-row.tsx +362 -0
- package/registry/ui/switch.tsx +246 -0
- package/registry/ui/tabs.tsx +348 -0
- package/registry/ui/textarea.tsx +265 -0
- package/registry/ui/toast.tsx +316 -0
- package/registry/ui/tooltip.tsx +369 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toast
|
|
3
|
+
*
|
|
4
|
+
* A notification system for displaying brief messages.
|
|
5
|
+
* Auto-dismisses after duration. Supports variants and actions.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // Using ToastProvider at app root
|
|
10
|
+
* <ToastProvider>
|
|
11
|
+
* <App />
|
|
12
|
+
* </ToastProvider>
|
|
13
|
+
*
|
|
14
|
+
* // Using toast anywhere in your app
|
|
15
|
+
* import { useToast } from '@/components/ui/toast';
|
|
16
|
+
*
|
|
17
|
+
* function MyComponent() {
|
|
18
|
+
* const { toast } = useToast();
|
|
19
|
+
*
|
|
20
|
+
* return (
|
|
21
|
+
* <Button onPress={() => toast({ title: 'Saved!', variant: 'success' })}>
|
|
22
|
+
* Save
|
|
23
|
+
* </Button>
|
|
24
|
+
* );
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import React, { createContext, useContext, useCallback, useRef, useEffect } from 'react';
|
|
30
|
+
import {
|
|
31
|
+
View,
|
|
32
|
+
Text,
|
|
33
|
+
StyleSheet,
|
|
34
|
+
ViewStyle,
|
|
35
|
+
TextStyle,
|
|
36
|
+
Pressable,
|
|
37
|
+
Dimensions,
|
|
38
|
+
} from 'react-native';
|
|
39
|
+
import Animated, {
|
|
40
|
+
useSharedValue,
|
|
41
|
+
useAnimatedStyle,
|
|
42
|
+
withSpring,
|
|
43
|
+
withTiming,
|
|
44
|
+
runOnJS,
|
|
45
|
+
SlideInUp,
|
|
46
|
+
SlideOutUp,
|
|
47
|
+
FadeIn,
|
|
48
|
+
FadeOut,
|
|
49
|
+
Layout,
|
|
50
|
+
} from 'react-native-reanimated';
|
|
51
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
52
|
+
import { useTheme, TOAST_CONSTANTS } from '@nativeui/core';
|
|
53
|
+
import { haptic } from '@nativeui/core';
|
|
54
|
+
|
|
55
|
+
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
56
|
+
|
|
57
|
+
export type ToastVariant = 'default' | 'success' | 'error' | 'warning';
|
|
58
|
+
|
|
59
|
+
export interface ToastData {
|
|
60
|
+
id: string;
|
|
61
|
+
title: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
variant?: ToastVariant;
|
|
64
|
+
duration?: number;
|
|
65
|
+
action?: {
|
|
66
|
+
label: string;
|
|
67
|
+
onPress: () => void;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ToastOptions {
|
|
72
|
+
title: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
variant?: ToastVariant;
|
|
75
|
+
duration?: number;
|
|
76
|
+
action?: {
|
|
77
|
+
label: string;
|
|
78
|
+
onPress: () => void;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ToastContextValue {
|
|
83
|
+
toast: (options: ToastOptions) => string;
|
|
84
|
+
dismiss: (id: string) => void;
|
|
85
|
+
dismissAll: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
89
|
+
|
|
90
|
+
export function useToast() {
|
|
91
|
+
const context = useContext(ToastContext);
|
|
92
|
+
if (!context) {
|
|
93
|
+
throw new Error('useToast must be used within a ToastProvider');
|
|
94
|
+
}
|
|
95
|
+
return context;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ToastProviderProps {
|
|
99
|
+
children: React.ReactNode;
|
|
100
|
+
/** Maximum number of toasts visible at once */
|
|
101
|
+
maxToasts?: number;
|
|
102
|
+
/** Default duration in ms */
|
|
103
|
+
defaultDuration?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ToastProvider({
|
|
107
|
+
children,
|
|
108
|
+
maxToasts = TOAST_CONSTANTS.maxToasts,
|
|
109
|
+
defaultDuration = TOAST_CONSTANTS.defaultDuration,
|
|
110
|
+
}: ToastProviderProps) {
|
|
111
|
+
const [toasts, setToasts] = React.useState<ToastData[]>([]);
|
|
112
|
+
const insets = useSafeAreaInsets();
|
|
113
|
+
|
|
114
|
+
const toast = useCallback(
|
|
115
|
+
(options: ToastOptions): string => {
|
|
116
|
+
const id = Math.random().toString(36).slice(2, 9);
|
|
117
|
+
const newToast: ToastData = {
|
|
118
|
+
id,
|
|
119
|
+
...options,
|
|
120
|
+
duration: options.duration ?? defaultDuration,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Haptic feedback based on variant
|
|
124
|
+
if (options.variant === 'success') {
|
|
125
|
+
haptic('success');
|
|
126
|
+
} else if (options.variant === 'error') {
|
|
127
|
+
haptic('error');
|
|
128
|
+
} else if (options.variant === 'warning') {
|
|
129
|
+
haptic('warning');
|
|
130
|
+
} else {
|
|
131
|
+
haptic('light');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setToasts((prev) => {
|
|
135
|
+
const next = [newToast, ...prev];
|
|
136
|
+
return next.slice(0, maxToasts);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return id;
|
|
140
|
+
},
|
|
141
|
+
[defaultDuration, maxToasts]
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const dismiss = useCallback((id: string) => {
|
|
145
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
const dismissAll = useCallback(() => {
|
|
149
|
+
setToasts([]);
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<ToastContext.Provider value={{ toast, dismiss, dismissAll }}>
|
|
154
|
+
{children}
|
|
155
|
+
<View
|
|
156
|
+
style={[
|
|
157
|
+
styles.container,
|
|
158
|
+
{ top: insets.top + TOAST_CONSTANTS.containerTopOffset },
|
|
159
|
+
]}
|
|
160
|
+
pointerEvents="box-none"
|
|
161
|
+
>
|
|
162
|
+
{toasts.map((t) => (
|
|
163
|
+
<ToastItem
|
|
164
|
+
key={t.id}
|
|
165
|
+
data={t}
|
|
166
|
+
onDismiss={() => dismiss(t.id)}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</View>
|
|
170
|
+
</ToastContext.Provider>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ToastItemProps {
|
|
175
|
+
data: ToastData;
|
|
176
|
+
onDismiss: () => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ToastItem({ data, onDismiss }: ToastItemProps) {
|
|
180
|
+
const { colors, radius, platformShadow, spacing } = useTheme();
|
|
181
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (data.duration && data.duration > 0) {
|
|
185
|
+
timerRef.current = setTimeout(onDismiss, data.duration);
|
|
186
|
+
}
|
|
187
|
+
return () => {
|
|
188
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
189
|
+
};
|
|
190
|
+
}, [data.duration, onDismiss]);
|
|
191
|
+
|
|
192
|
+
const getVariantStyles = () => {
|
|
193
|
+
switch (data.variant) {
|
|
194
|
+
case 'success':
|
|
195
|
+
return {
|
|
196
|
+
backgroundColor: colors.success ?? TOAST_CONSTANTS.fallbackColors.success,
|
|
197
|
+
textColor: TOAST_CONSTANTS.fallbackColors.successForeground,
|
|
198
|
+
};
|
|
199
|
+
case 'error':
|
|
200
|
+
return {
|
|
201
|
+
backgroundColor: colors.destructive,
|
|
202
|
+
textColor: colors.destructiveForeground,
|
|
203
|
+
};
|
|
204
|
+
case 'warning':
|
|
205
|
+
return {
|
|
206
|
+
backgroundColor: colors.warning ?? TOAST_CONSTANTS.fallbackColors.warning,
|
|
207
|
+
textColor: TOAST_CONSTANTS.fallbackColors.warningForeground,
|
|
208
|
+
};
|
|
209
|
+
default:
|
|
210
|
+
return {
|
|
211
|
+
backgroundColor: colors.card,
|
|
212
|
+
textColor: colors.foreground,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const variantStyles = getVariantStyles();
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Animated.View
|
|
221
|
+
entering={SlideInUp.springify().damping(20).stiffness(200)}
|
|
222
|
+
exiting={FadeOut.duration(TOAST_CONSTANTS.fadeOutDuration)}
|
|
223
|
+
layout={Layout.springify().damping(20)}
|
|
224
|
+
style={[
|
|
225
|
+
styles.toast,
|
|
226
|
+
{
|
|
227
|
+
backgroundColor: variantStyles.backgroundColor,
|
|
228
|
+
borderRadius: radius.lg,
|
|
229
|
+
padding: spacing[4],
|
|
230
|
+
marginHorizontal: spacing[4],
|
|
231
|
+
marginBottom: spacing[2],
|
|
232
|
+
},
|
|
233
|
+
platformShadow('lg'),
|
|
234
|
+
]}
|
|
235
|
+
>
|
|
236
|
+
<Pressable
|
|
237
|
+
style={styles.toastContent}
|
|
238
|
+
onPress={onDismiss}
|
|
239
|
+
>
|
|
240
|
+
<View style={styles.textContainer}>
|
|
241
|
+
<Text style={[styles.title, { color: variantStyles.textColor }]}>
|
|
242
|
+
{data.title}
|
|
243
|
+
</Text>
|
|
244
|
+
{data.description && (
|
|
245
|
+
<Text
|
|
246
|
+
style={[
|
|
247
|
+
styles.description,
|
|
248
|
+
{
|
|
249
|
+
color: variantStyles.textColor,
|
|
250
|
+
opacity: 0.9,
|
|
251
|
+
marginTop: spacing[1],
|
|
252
|
+
},
|
|
253
|
+
]}
|
|
254
|
+
>
|
|
255
|
+
{data.description}
|
|
256
|
+
</Text>
|
|
257
|
+
)}
|
|
258
|
+
</View>
|
|
259
|
+
{data.action && (
|
|
260
|
+
<Pressable
|
|
261
|
+
onPress={() => {
|
|
262
|
+
data.action?.onPress();
|
|
263
|
+
onDismiss();
|
|
264
|
+
}}
|
|
265
|
+
style={[
|
|
266
|
+
styles.actionButton,
|
|
267
|
+
{
|
|
268
|
+
marginLeft: spacing[3],
|
|
269
|
+
paddingHorizontal: spacing[3],
|
|
270
|
+
paddingVertical: spacing[1.5],
|
|
271
|
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
272
|
+
borderRadius: radius.md,
|
|
273
|
+
},
|
|
274
|
+
]}
|
|
275
|
+
>
|
|
276
|
+
<Text style={[styles.actionText, { color: variantStyles.textColor }]}>
|
|
277
|
+
{data.action.label}
|
|
278
|
+
</Text>
|
|
279
|
+
</Pressable>
|
|
280
|
+
)}
|
|
281
|
+
</Pressable>
|
|
282
|
+
</Animated.View>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const styles = StyleSheet.create({
|
|
287
|
+
container: {
|
|
288
|
+
position: 'absolute',
|
|
289
|
+
left: 0,
|
|
290
|
+
right: 0,
|
|
291
|
+
zIndex: 9999,
|
|
292
|
+
},
|
|
293
|
+
toast: {
|
|
294
|
+
width: SCREEN_WIDTH - TOAST_CONSTANTS.widthMargin,
|
|
295
|
+
alignSelf: 'center',
|
|
296
|
+
},
|
|
297
|
+
toastContent: {
|
|
298
|
+
flexDirection: 'row',
|
|
299
|
+
alignItems: 'center',
|
|
300
|
+
},
|
|
301
|
+
textContainer: {
|
|
302
|
+
flex: 1,
|
|
303
|
+
},
|
|
304
|
+
title: {
|
|
305
|
+
fontSize: 14,
|
|
306
|
+
fontWeight: '600',
|
|
307
|
+
},
|
|
308
|
+
description: {
|
|
309
|
+
fontSize: 13,
|
|
310
|
+
},
|
|
311
|
+
actionButton: {},
|
|
312
|
+
actionText: {
|
|
313
|
+
fontSize: 13,
|
|
314
|
+
fontWeight: '600',
|
|
315
|
+
},
|
|
316
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooltip
|
|
3
|
+
*
|
|
4
|
+
* Small popup that appears on long-press to show additional information.
|
|
5
|
+
* Automatically positions itself above or below the trigger element.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // Basic tooltip
|
|
10
|
+
* <Tooltip content="This is helpful information">
|
|
11
|
+
* <Button>Long press me</Button>
|
|
12
|
+
* </Tooltip>
|
|
13
|
+
*
|
|
14
|
+
* // With custom delay
|
|
15
|
+
* <Tooltip content="Appears after 500ms" delayMs={500}>
|
|
16
|
+
* <IconButton icon={<InfoIcon />} />
|
|
17
|
+
* </Tooltip>
|
|
18
|
+
*
|
|
19
|
+
* // Positioned below
|
|
20
|
+
* <Tooltip content="I appear below" position="bottom">
|
|
21
|
+
* <Text>Long press me</Text>
|
|
22
|
+
* </Tooltip>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import React, { useState, useRef, useCallback, Children, isValidElement, cloneElement } from 'react';
|
|
27
|
+
import {
|
|
28
|
+
View,
|
|
29
|
+
Text,
|
|
30
|
+
Pressable,
|
|
31
|
+
StyleSheet,
|
|
32
|
+
Modal,
|
|
33
|
+
Dimensions,
|
|
34
|
+
LayoutRectangle,
|
|
35
|
+
} from 'react-native';
|
|
36
|
+
import Animated, {
|
|
37
|
+
useAnimatedStyle,
|
|
38
|
+
useSharedValue,
|
|
39
|
+
withTiming,
|
|
40
|
+
withSpring,
|
|
41
|
+
} from 'react-native-reanimated';
|
|
42
|
+
import { useTheme, haptic } from '@nativeui/core';
|
|
43
|
+
|
|
44
|
+
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
// Types
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
export type TooltipPosition = 'top' | 'bottom';
|
|
51
|
+
|
|
52
|
+
export interface TooltipProps {
|
|
53
|
+
/** Tooltip content text */
|
|
54
|
+
content: string;
|
|
55
|
+
/** Trigger element */
|
|
56
|
+
children: React.ReactNode;
|
|
57
|
+
/** Preferred position (auto-adjusts if not enough space) */
|
|
58
|
+
position?: TooltipPosition;
|
|
59
|
+
/** Delay before showing tooltip (ms) */
|
|
60
|
+
delayMs?: number;
|
|
61
|
+
/** Controlled open state */
|
|
62
|
+
open?: boolean;
|
|
63
|
+
/** Callback when open state changes */
|
|
64
|
+
onOpenChange?: (open: boolean) => void;
|
|
65
|
+
/** Whether tooltip is disabled */
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
/** Maximum width of tooltip */
|
|
68
|
+
maxWidth?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// Constants
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const TOOLTIP_PADDING = 12;
|
|
76
|
+
const TOOLTIP_MARGIN = 8;
|
|
77
|
+
const ARROW_SIZE = 8;
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// Tooltip Component
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export function Tooltip({
|
|
84
|
+
content,
|
|
85
|
+
children,
|
|
86
|
+
position = 'top',
|
|
87
|
+
delayMs = 500,
|
|
88
|
+
open: controlledOpen,
|
|
89
|
+
onOpenChange,
|
|
90
|
+
disabled = false,
|
|
91
|
+
maxWidth = 250,
|
|
92
|
+
}: TooltipProps) {
|
|
93
|
+
const { colors, radius, fontSize } = useTheme();
|
|
94
|
+
|
|
95
|
+
const isControlled = controlledOpen !== undefined;
|
|
96
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
97
|
+
const isOpen = isControlled ? controlledOpen : internalOpen;
|
|
98
|
+
|
|
99
|
+
const [triggerLayout, setTriggerLayout] = useState<LayoutRectangle | null>(null);
|
|
100
|
+
const [tooltipSize, setTooltipSize] = useState({ width: 0, height: 0 });
|
|
101
|
+
const triggerRef = useRef<View>(null);
|
|
102
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
103
|
+
|
|
104
|
+
const opacity = useSharedValue(0);
|
|
105
|
+
const scale = useSharedValue(0.9);
|
|
106
|
+
|
|
107
|
+
const setOpen = useCallback(
|
|
108
|
+
(value: boolean) => {
|
|
109
|
+
if (isControlled) {
|
|
110
|
+
onOpenChange?.(value);
|
|
111
|
+
} else {
|
|
112
|
+
setInternalOpen(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (value) {
|
|
116
|
+
opacity.value = withTiming(1, { duration: 150 });
|
|
117
|
+
scale.value = withSpring(1, { damping: 20, stiffness: 300 });
|
|
118
|
+
} else {
|
|
119
|
+
opacity.value = withTiming(0, { duration: 100 });
|
|
120
|
+
scale.value = withTiming(0.9, { duration: 100 });
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
[isControlled, onOpenChange, opacity, scale]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const showTooltip = useCallback(() => {
|
|
127
|
+
if (triggerRef.current) {
|
|
128
|
+
triggerRef.current.measureInWindow((x, y, width, height) => {
|
|
129
|
+
setTriggerLayout({ x, y, width, height });
|
|
130
|
+
haptic('light');
|
|
131
|
+
setOpen(true);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}, [setOpen]);
|
|
135
|
+
|
|
136
|
+
// Timer-based approach for showing tooltip after delay
|
|
137
|
+
// Works consistently for both pressable and non-pressable components
|
|
138
|
+
const startDelayTimer = useCallback((customDelay?: number) => {
|
|
139
|
+
if (disabled) return;
|
|
140
|
+
if (timerRef.current) {
|
|
141
|
+
clearTimeout(timerRef.current);
|
|
142
|
+
}
|
|
143
|
+
const delay = customDelay ?? delayMs;
|
|
144
|
+
timerRef.current = setTimeout(() => {
|
|
145
|
+
showTooltip();
|
|
146
|
+
}, delay);
|
|
147
|
+
}, [disabled, delayMs, showTooltip]);
|
|
148
|
+
|
|
149
|
+
const cancelDelayTimer = useCallback(() => {
|
|
150
|
+
if (timerRef.current) {
|
|
151
|
+
clearTimeout(timerRef.current);
|
|
152
|
+
timerRef.current = null;
|
|
153
|
+
}
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
const handleClose = useCallback(() => {
|
|
157
|
+
setOpen(false);
|
|
158
|
+
}, [setOpen]);
|
|
159
|
+
|
|
160
|
+
// Calculate tooltip position
|
|
161
|
+
const calculatePosition = useCallback((): {
|
|
162
|
+
top: number;
|
|
163
|
+
left: number;
|
|
164
|
+
actualPosition: TooltipPosition;
|
|
165
|
+
} => {
|
|
166
|
+
if (!triggerLayout) {
|
|
167
|
+
return { top: 0, left: 0, actualPosition: position };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { x: triggerX, y: triggerY, width: triggerWidth, height: triggerHeight } = triggerLayout;
|
|
171
|
+
|
|
172
|
+
// Center horizontally
|
|
173
|
+
let left = triggerX + triggerWidth / 2 - tooltipSize.width / 2;
|
|
174
|
+
|
|
175
|
+
// Clamp to screen bounds
|
|
176
|
+
left = Math.max(TOOLTIP_MARGIN, Math.min(left, SCREEN_WIDTH - tooltipSize.width - TOOLTIP_MARGIN));
|
|
177
|
+
|
|
178
|
+
// Calculate vertical position
|
|
179
|
+
let actualPosition = position;
|
|
180
|
+
let top: number;
|
|
181
|
+
|
|
182
|
+
if (position === 'top') {
|
|
183
|
+
top = triggerY - tooltipSize.height - ARROW_SIZE - 4;
|
|
184
|
+
// If not enough space above, show below
|
|
185
|
+
if (top < TOOLTIP_MARGIN) {
|
|
186
|
+
actualPosition = 'bottom';
|
|
187
|
+
top = triggerY + triggerHeight + ARROW_SIZE + 4;
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
top = triggerY + triggerHeight + ARROW_SIZE + 4;
|
|
191
|
+
// If not enough space below, show above
|
|
192
|
+
if (top + tooltipSize.height > SCREEN_HEIGHT - TOOLTIP_MARGIN) {
|
|
193
|
+
actualPosition = 'top';
|
|
194
|
+
top = triggerY - tooltipSize.height - ARROW_SIZE - 4;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { top, left, actualPosition };
|
|
199
|
+
}, [triggerLayout, tooltipSize, position]);
|
|
200
|
+
|
|
201
|
+
const { top, left, actualPosition } = calculatePosition();
|
|
202
|
+
|
|
203
|
+
// Calculate arrow position
|
|
204
|
+
const arrowLeft = triggerLayout
|
|
205
|
+
? triggerLayout.x + triggerLayout.width / 2 - left - ARROW_SIZE
|
|
206
|
+
: 0;
|
|
207
|
+
|
|
208
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
209
|
+
opacity: opacity.value,
|
|
210
|
+
transform: [{ scale: scale.value }],
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
const handleTooltipLayout = useCallback(
|
|
214
|
+
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
|
215
|
+
const { width, height } = event.nativeEvent.layout;
|
|
216
|
+
if (width !== tooltipSize.width || height !== tooltipSize.height) {
|
|
217
|
+
setTooltipSize({ width, height });
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[tooltipSize]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Render the trigger element
|
|
224
|
+
const renderTrigger = () => {
|
|
225
|
+
const child = Children.only(children);
|
|
226
|
+
|
|
227
|
+
if (isValidElement(child)) {
|
|
228
|
+
const childProps = child.props as Record<string, unknown>;
|
|
229
|
+
|
|
230
|
+
// Check if child is a pressable component (has onPress or similar)
|
|
231
|
+
const componentName = typeof child.type === 'function'
|
|
232
|
+
? ((child.type as any).displayName || child.type.name || '')
|
|
233
|
+
: '';
|
|
234
|
+
const isPressable =
|
|
235
|
+
'onPress' in childProps ||
|
|
236
|
+
'onLongPress' in childProps ||
|
|
237
|
+
'onPressIn' in childProps ||
|
|
238
|
+
child.type === Pressable ||
|
|
239
|
+
['Button', 'IconButton', 'Pressable', 'TouchableOpacity', 'TouchableHighlight'].includes(componentName);
|
|
240
|
+
|
|
241
|
+
// IconButtons should show tooltip immediately (no delay) for better UX
|
|
242
|
+
const isIconButton = componentName === 'IconButton';
|
|
243
|
+
const effectiveDelay = isIconButton ? 0 : delayMs;
|
|
244
|
+
|
|
245
|
+
if (isPressable) {
|
|
246
|
+
// Clone and inject onPressIn/onPressOut for pressable components
|
|
247
|
+
// Use timer-based approach for consistent delay behavior
|
|
248
|
+
const existingOnPressIn = childProps.onPressIn as ((e: any) => void) | undefined;
|
|
249
|
+
const existingOnPressOut = childProps.onPressOut as ((e: any) => void) | undefined;
|
|
250
|
+
|
|
251
|
+
return cloneElement(child as React.ReactElement<Record<string, unknown>>, {
|
|
252
|
+
ref: triggerRef,
|
|
253
|
+
onPressIn: (e: any) => {
|
|
254
|
+
startDelayTimer(effectiveDelay);
|
|
255
|
+
existingOnPressIn?.(e);
|
|
256
|
+
},
|
|
257
|
+
onPressOut: (e: any) => {
|
|
258
|
+
cancelDelayTimer();
|
|
259
|
+
existingOnPressOut?.(e);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// For non-pressable elements, wrap in a View with touch handlers
|
|
266
|
+
return (
|
|
267
|
+
<View
|
|
268
|
+
ref={triggerRef}
|
|
269
|
+
collapsable={false}
|
|
270
|
+
onTouchStart={() => startDelayTimer()}
|
|
271
|
+
onTouchEnd={cancelDelayTimer}
|
|
272
|
+
onTouchCancel={cancelDelayTimer}
|
|
273
|
+
>
|
|
274
|
+
{children}
|
|
275
|
+
</View>
|
|
276
|
+
);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<>
|
|
281
|
+
{renderTrigger()}
|
|
282
|
+
|
|
283
|
+
<Modal visible={isOpen} transparent animationType="none" statusBarTranslucent>
|
|
284
|
+
<Pressable style={styles.overlay} onPress={handleClose}>
|
|
285
|
+
<Animated.View
|
|
286
|
+
style={[
|
|
287
|
+
styles.tooltip,
|
|
288
|
+
{
|
|
289
|
+
backgroundColor: colors.foreground,
|
|
290
|
+
borderRadius: radius.md,
|
|
291
|
+
maxWidth,
|
|
292
|
+
top,
|
|
293
|
+
left,
|
|
294
|
+
},
|
|
295
|
+
animatedStyle,
|
|
296
|
+
]}
|
|
297
|
+
onLayout={handleTooltipLayout}
|
|
298
|
+
>
|
|
299
|
+
{/* Arrow */}
|
|
300
|
+
<View
|
|
301
|
+
style={[
|
|
302
|
+
styles.arrow,
|
|
303
|
+
actualPosition === 'top' ? styles.arrowBottom : styles.arrowTop,
|
|
304
|
+
{
|
|
305
|
+
borderTopColor: actualPosition === 'top' ? colors.foreground : 'transparent',
|
|
306
|
+
borderBottomColor: actualPosition === 'bottom' ? colors.foreground : 'transparent',
|
|
307
|
+
left: Math.max(ARROW_SIZE, Math.min(arrowLeft, maxWidth - ARROW_SIZE * 3)),
|
|
308
|
+
},
|
|
309
|
+
]}
|
|
310
|
+
/>
|
|
311
|
+
|
|
312
|
+
{/* Content */}
|
|
313
|
+
<Text
|
|
314
|
+
style={[
|
|
315
|
+
styles.content,
|
|
316
|
+
{
|
|
317
|
+
color: colors.background,
|
|
318
|
+
fontSize: fontSize.sm,
|
|
319
|
+
paddingHorizontal: TOOLTIP_PADDING,
|
|
320
|
+
paddingVertical: TOOLTIP_PADDING - 4,
|
|
321
|
+
},
|
|
322
|
+
]}
|
|
323
|
+
>
|
|
324
|
+
{content}
|
|
325
|
+
</Text>
|
|
326
|
+
</Animated.View>
|
|
327
|
+
</Pressable>
|
|
328
|
+
</Modal>
|
|
329
|
+
</>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
334
|
+
// Styles
|
|
335
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
const styles = StyleSheet.create({
|
|
338
|
+
overlay: {
|
|
339
|
+
flex: 1,
|
|
340
|
+
},
|
|
341
|
+
tooltip: {
|
|
342
|
+
position: 'absolute',
|
|
343
|
+
shadowColor: '#000',
|
|
344
|
+
shadowOffset: { width: 0, height: 2 },
|
|
345
|
+
shadowOpacity: 0.2,
|
|
346
|
+
shadowRadius: 8,
|
|
347
|
+
elevation: 8,
|
|
348
|
+
},
|
|
349
|
+
arrow: {
|
|
350
|
+
position: 'absolute',
|
|
351
|
+
width: 0,
|
|
352
|
+
height: 0,
|
|
353
|
+
borderLeftWidth: ARROW_SIZE,
|
|
354
|
+
borderRightWidth: ARROW_SIZE,
|
|
355
|
+
borderLeftColor: 'transparent',
|
|
356
|
+
borderRightColor: 'transparent',
|
|
357
|
+
},
|
|
358
|
+
arrowTop: {
|
|
359
|
+
top: -ARROW_SIZE,
|
|
360
|
+
borderBottomWidth: ARROW_SIZE,
|
|
361
|
+
},
|
|
362
|
+
arrowBottom: {
|
|
363
|
+
bottom: -ARROW_SIZE,
|
|
364
|
+
borderTopWidth: ARROW_SIZE,
|
|
365
|
+
},
|
|
366
|
+
content: {
|
|
367
|
+
textAlign: 'center',
|
|
368
|
+
},
|
|
369
|
+
});
|