Skip to content

Commit c37cb7b

Browse files
committed
feat: add ActionSheet and ActionSheetSelect UI components and enhance AI Chat Native Provider with form and action bar elements.
1 parent fbe0038 commit c37cb7b

4 files changed

Lines changed: 320 additions & 5 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import React, { useCallback, useContext, useState } from 'react';
2+
import { View, Pressable } from 'react-native';
3+
import { ActionSheet, ActionSheetContent, ActionSheetTrigger, useActionSheet } from './action-sheet';
4+
import { Icon } from './icon';
5+
import { Text } from './text';
6+
import { cn } from '~/utils/cn';
7+
8+
type ActionSheetSelectContextType = {
9+
labels: Record<string, React.ReactNode>;
10+
value: string;
11+
setValue: (t: string) => void;
12+
};
13+
14+
const ActionSheetSelectContext = React.createContext<ActionSheetSelectContextType>({
15+
labels: {} as Record<string, string>,
16+
value: '',
17+
setValue: () => {},
18+
});
19+
20+
const useActionSheetSelect = (): ActionSheetSelectContextType => {
21+
const ctx = useContext(ActionSheetSelectContext);
22+
if (!ctx) {
23+
throw new Error('action sheet context not found');
24+
}
25+
return ctx;
26+
};
27+
28+
export const ActionSheetSelect = ({
29+
children,
30+
labels,
31+
value: controlledValue,
32+
onValueChange,
33+
}: {
34+
children: React.ReactNode;
35+
labels: Record<string, React.ReactNode>;
36+
value?: string;
37+
onValueChange?: (t: string) => void;
38+
}) => {
39+
const [localValue, setValue] = useState<string | null>(null);
40+
const value = controlledValue ?? localValue;
41+
42+
const handleChange = useCallback(
43+
(value: string) => {
44+
onValueChange?.(value);
45+
setValue(value);
46+
},
47+
[onValueChange, value],
48+
);
49+
50+
return (
51+
<ActionSheetSelectContext.Provider value={{ labels, value, setValue: handleChange }}>
52+
<ActionSheet>{children}</ActionSheet>
53+
</ActionSheetSelectContext.Provider>
54+
);
55+
};
56+
57+
export const ActionSheetSelectTrigger: React.FC<React.ComponentPropsWithRef<typeof ActionSheetTrigger>> = ({
58+
children,
59+
className,
60+
onBlur,
61+
onFocus,
62+
...props
63+
}) => {
64+
const { open } = useActionSheet();
65+
66+
return (
67+
<ActionSheetTrigger
68+
className={cn(
69+
'h-12 flex-row items-center justify-between rounded-xl border px-3',
70+
open ? 'border-black dark:border-white' : 'border-input',
71+
className,
72+
)}
73+
{...props}
74+
>
75+
{children}
76+
</ActionSheetTrigger>
77+
);
78+
};
79+
80+
interface ActionSheetSelectValueProps {
81+
className?: string;
82+
placeholder?: string;
83+
}
84+
85+
export const ActionSheetSelectValue: React.FC<ActionSheetSelectValueProps> = ({ className, placeholder }) => {
86+
const { value, labels } = useActionSheetSelect();
87+
const label = labels[value] ?? null;
88+
return <Text className={className}>{label ?? placeholder ?? 'Select...'}</Text>;
89+
};
90+
91+
export const ActionSheetSelectContent: React.FC<React.ComponentPropsWithRef<typeof ActionSheetContent>> = ({
92+
children,
93+
...props
94+
}) => {
95+
return (
96+
<ActionSheetContent {...props}>
97+
<View className={'flex p-4 pt-2 pb-8'}>{children}</View>
98+
</ActionSheetContent>
99+
);
100+
};
101+
102+
interface ActionSheetSelectItemProps {
103+
className?: string;
104+
value: string;
105+
}
106+
107+
export const ActionSheetSelectItem: React.FC<ActionSheetSelectItemProps> = ({ className, value, ...props }) => {
108+
const { value: controlledValue, setValue, labels } = useActionSheetSelect();
109+
const label = labels[value] ?? '';
110+
const { setOpen } = useActionSheet();
111+
112+
const handleChange = useCallback(() => {
113+
setValue(value);
114+
setOpen(false);
115+
}, [value, useActionSheet, setOpen]);
116+
117+
return (
118+
<Pressable
119+
{...props}
120+
className={cn(
121+
'flex-row items-center gap-2 rounded-xl px-3 py-1.5',
122+
value === controlledValue ? 'bg-accent' : '',
123+
className,
124+
)}
125+
onPress={handleChange}
126+
>
127+
<View className={'flex-row items-center gap-2'}>
128+
<Text className="flex-1">{label}</Text>
129+
{value === controlledValue && <Icon name="Check" className="h-4 w-4" size={20} />}
130+
</View>
131+
</Pressable>
132+
);
133+
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as Slot from '@rn-primitives/slot';
2+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
3+
import { GestureResponderEvent } from 'react-native';
4+
import RNActionSheet, {
5+
ActionSheetProps as RNActionSheetProps,
6+
ActionSheetRef as RNActionSheetRef,
7+
} from 'react-native-actions-sheet';
8+
import { Pressable } from 'react-native';
9+
10+
export type ActionSheetContextType = {
11+
ref: React.RefObject<RNActionSheetRef | null>;
12+
open: boolean;
13+
setOpen: (newOpen: boolean) => void;
14+
};
15+
16+
const ActionSheetContext = React.createContext<ActionSheetContextType>({
17+
ref: {} as React.RefObject<RNActionSheetRef | null>,
18+
open: false,
19+
setOpen: () => {},
20+
});
21+
22+
export const useActionSheet = (): ActionSheetContextType => {
23+
const ctx = useContext(ActionSheetContext);
24+
if (!ctx) {
25+
throw new Error('action sheet context not found');
26+
}
27+
return ctx;
28+
};
29+
30+
export const ActionSheet = ({
31+
children,
32+
open: propOpen,
33+
onOpenChange: propSetOpen,
34+
ref,
35+
}: {
36+
children: React.ReactNode;
37+
open?: boolean;
38+
onOpenChange?: (open: boolean) => void;
39+
} & Partial<ActionSheetContextType>) => {
40+
const [open, setOpen] = useState<boolean>(false);
41+
const actionSheetRef = useRef<RNActionSheetRef>(null);
42+
const finalRef = ref ?? actionSheetRef;
43+
const finalOpen = useMemo(() => propOpen ?? open, [propOpen, open]);
44+
45+
useEffect(() => {
46+
if (finalOpen) {
47+
finalRef.current?.show();
48+
} else {
49+
finalRef.current?.hide();
50+
}
51+
}, [finalOpen]);
52+
53+
const handleOpen = useCallback(
54+
(newOpen: boolean) => {
55+
propSetOpen?.(newOpen);
56+
setOpen(newOpen);
57+
},
58+
[propSetOpen],
59+
);
60+
61+
return (
62+
<ActionSheetContext.Provider value={{ ref: finalRef, open: finalOpen, setOpen: handleOpen }}>
63+
{children}
64+
</ActionSheetContext.Provider>
65+
);
66+
};
67+
68+
export const ActionSheetTrigger: React.FC<React.ComponentPropsWithRef<typeof Pressable> & { asChild?: boolean }> = ({
69+
children,
70+
asChild,
71+
onPress,
72+
...props
73+
}) => {
74+
const { ref } = useActionSheet();
75+
const Comp = asChild ? Slot.Pressable : Pressable;
76+
77+
const handleOpen = useCallback((event: GestureResponderEvent) => {
78+
onPress?.(event);
79+
ref.current?.show();
80+
}, []);
81+
82+
return (
83+
<Comp {...props} onPress={handleOpen}>
84+
{children}
85+
</Comp>
86+
);
87+
};
88+
89+
interface ThemedActionSheetProps extends RNActionSheetProps {}
90+
91+
export const ActionSheetContent: React.FC<ThemedActionSheetProps> = ({ containerStyle, ...props }) => {
92+
const { ref, setOpen } = useActionSheet();
93+
94+
return (
95+
<RNActionSheet
96+
{...props}
97+
ref={ref}
98+
onClose={() => setOpen(false)}
99+
onOpen={() => setOpen(true)}
100+
containerStyle={{
101+
paddingBottom: 24,
102+
// backgroundColor: colors['--color-secondary'],
103+
borderTopLeftRadius: 20,
104+
borderTopRightRadius: 20,
105+
// @ts-expect-error
106+
...containerStyle,
107+
}}
108+
gestureEnabled
109+
/>
110+
);
111+
};

apps/mobile/components/ui/icon.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Href, Link } from 'expo-router';
33
import type * as LucideIcons from 'lucide-react-native';
44
import {
55
ArrowLeftIcon,
6+
CheckIcon,
67
XIcon,
78
} from 'lucide-react-native';
89
import React from 'react';
@@ -15,6 +16,7 @@ type AllLucideIconName = Exclude<keyof typeof LucideIcons, 'createLucideIcon' |
1516

1617
const lucideIcons = {
1718
ArrowLeft: ArrowLeftIcon,
19+
Check: CheckIcon,
1820
X: XIcon,
1921
} satisfies {
2022
[key in AllLucideIconName]?: LucideIcons.LucideIcon;

packages-test-3/ai-react-native/src/ai-chat-native-provider.tsx

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { View, Text, Pressable, ScrollView, TextInput } from 'react-native';
2+
import { View, Text, Pressable, ScrollView, TextInput, Image as NativeImage } from 'react-native';
33
import { RuntimeProvider } from '@creatorem/ai-chat/runtime';
44
import type { RuntimeHooks } from '@creatorem/ai-chat/hook-types';
55
import type { RuntimeComponents } from "@creatorem/ai-chat/component-types";
@@ -10,6 +10,7 @@ import { useNativeHover } from './hooks/use-native-hover';
1010
const NativeComponents: RuntimeComponents = {
1111
Box: ({ className, ...props }) => <View {...props} />,
1212
Text: ({ className, ...props }) => <Text {...props} />,
13+
Form: ({ className, onSubmit, ...props }) => <View {...props} />,
1314
Button: ({ className, onClick, ...props }) => (
1415
<Pressable onPress={onClick} {...props}>
1516
{props.children}
@@ -18,17 +19,85 @@ const NativeComponents: RuntimeComponents = {
1819
ScrollArea: ({ className, ...props }) => <ScrollView {...props} />,
1920
Input: ({ className, onChange, ...props }) => (
2021
<TextInput
21-
onChangeText={(t) => onChange?.({ target: { value: t } })}
22+
onChangeText={(t) => onChange?.({ target: { value: t } } as any)}
2223
{...props}
2324
/>
2425
),
25-
Textarea: ({ className, onChange, ...props }) => (
26+
Textarea: ({ className, onChange, minRows, maxRows, onHeightChange, cacheMeasurements, rowHeight, ...props }) => (
2627
<TextInput
2728
multiline
28-
onChangeText={(t) => onChange?.({ target: { value: t } })}
29+
onChangeText={(t) => onChange?.({ target: { value: t } } as any)}
2930
{...props}
3031
/>
31-
)
32+
),
33+
34+
// Action bar
35+
ActionBarRoot: ({ children, ...props }) => <View {...props} style={[{ flexDirection: 'row' }, props.style]}>{children}</View>,
36+
ActionBarPortal: ({ children }) => <>{children}</>,
37+
ActionBarContent: ({ children, sideOffset, ...props }) => <View {...props}>{children}</View>,
38+
ActionBarItem: ({ children, ...props }) => <View {...props}>{children}</View>,
39+
ActionBarSeparator: ({ ...props }) => <View {...props} style={[{ width: 1, backgroundColor: '#ccc', marginHorizontal: 4 }, props.style]} />,
40+
ActionBarTrigger: ({ children, ...props }) => <Pressable {...props}>{children}</Pressable>,
41+
42+
// Thread List Item More
43+
ThreadListItemMoreRoot: ({ children, ...props }) => <View {...props}>{children}</View>,
44+
ThreadListItemMorePortal: ({ children }) => <>{children}</>,
45+
ThreadListItemMoreContent: ({ children, sideOffset, ...props }) => <View {...props}>{children}</View>,
46+
ThreadListItemMoreItem: ({ children, ...props }) => <Pressable {...props}>{children}</Pressable>,
47+
ThreadListItemMoreSeparator: ({ ...props }) => <View {...props} style={[{ height: 1, backgroundColor: '#ccc', marginVertical: 4 }, props.style]} />,
48+
ThreadListItemMoreTrigger: ({ children, ...props }) => <Pressable {...props}>{children}</Pressable>,
49+
50+
// Content Components
51+
Markdown: ({ content, className, ...props }) => <Text {...props}>{content}</Text>,
52+
CodeBlock: ({ value, language, className, ...props }) => (
53+
<View {...props} style={[{ backgroundColor: '#f0f0f0', padding: 8, borderRadius: 4 }, props.style]}>
54+
<Text style={{ fontFamily: 'monospace' }}>{value}</Text>
55+
</View>
56+
),
57+
Pre: ({ children, ...props }) => <View {...props}>{children}</View>,
58+
59+
// Media Components
60+
Image: ({ src, alt, className, ...props }) => (
61+
<NativeImage source={{ uri: src }} accessibilityLabel={alt} {...props} style={[{ width: 200, height: 200 }, props.style]} />
62+
),
63+
Avatar: ({ src, fallback, className }) => (
64+
<View style={{ width: 40, height: 40, borderRadius: 20, overflow: 'hidden', backgroundColor: '#e0e0e0' }}>
65+
{src ? <NativeImage source={{ uri: src }} style={{ width: '100%', height: '100%' }} /> : <Text>{fallback}</Text>}
66+
</View>
67+
),
68+
69+
// Attachments
70+
ComposerPrimitiveAddAttachment: ({ onClick, children, ...props }) => (
71+
<Pressable onPress={onClick} {...props}>
72+
{children}
73+
</Pressable>
74+
),
75+
76+
Attachment: ({ name, contentType, url, size, onRemove, className }) => (
77+
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 8, backgroundColor: '#f5f5f5', borderRadius: 4 }}>
78+
<Text>{name}</Text>
79+
{onRemove && (
80+
<Pressable onPress={onRemove} style={{ marginLeft: 8 }}>
81+
<Text>X</Text>
82+
</Pressable>
83+
)}
84+
</View>
85+
),
86+
87+
// Layout
88+
Separator: ({ orientation = 'horizontal', className, ...props }) => (
89+
<View
90+
{...props}
91+
style={[
92+
orientation === 'horizontal' ? { height: 1, width: '100%' } : { width: 1, height: '100%' },
93+
{ backgroundColor: '#e0e0e0' },
94+
props.style
95+
]}
96+
/>
97+
),
98+
99+
// Logic/Wrappers
100+
MessageSpacer: ({ children, ...props }) => <View {...props}>{children}</View>,
32101
};
33102

34103
const NativeHooks: RuntimeHooks = {

0 commit comments

Comments
 (0)