@nativetail/ui 0.0.8 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nativetail/ui",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {},
@@ -26,12 +26,13 @@
26
26
  "react-native": ">=0.63.0"
27
27
  },
28
28
  "dependencies": {
29
- "@gorhom/bottom-sheet": "^5.0.0-alpha.10",
29
+ "@hookform/resolvers": "^3.6.0",
30
+ "@nativetail/core": "^0.0.2",
30
31
  "@shopify/flash-list": "^1.7.0",
32
+ "countries-list": "^3.1.0",
31
33
  "expo-blur": "^13.0.2",
32
34
  "expo-linear-gradient": "~13.0.2",
33
35
  "moti": "^0.29.0",
34
- "@nativetail/core": "*",
35
36
  "react": "18.2.0",
36
37
  "react-hook-form": "^7.51.0",
37
38
  "react-native": "0.74.3",
@@ -40,8 +41,7 @@
40
41
  "react-native-reanimated": "~3.10.1",
41
42
  "react-native-safe-area-context": "4.10.1",
42
43
  "tailwind-merge": "^2.3.0",
43
- "zustand": "^4.5.2",
44
- "@hookform/resolvers": "^3.6.0"
44
+ "zustand": "^4.5.2"
45
45
  },
46
46
  "devDependencies": {
47
47
  "metro-react-native-babel-preset": "^0.77.0",
@@ -11,6 +11,7 @@ export type BottomSheetProps = ActionSheetProps & {
11
11
  indicatorClassName?: string;
12
12
  useBlur?: boolean;
13
13
  };
14
+ export type BottomSheetRef = ActionSheetRef;
14
15
  export const BottomSheet = forwardRef<ActionSheetRef, BottomSheetProps>(
15
16
  function BottomSheet(
16
17
  {
@@ -27,7 +28,7 @@ export const BottomSheet = forwardRef<ActionSheetRef, BottomSheetProps>(
27
28
  return (
28
29
  <ActionSheet
29
30
  ref={ref}
30
- containerStyle={tw.style("bg-background/95", containerClassName)}
31
+ containerStyle={tw.style("bg-background", containerClassName)}
31
32
  gestureEnabled
32
33
  indicatorStyle={tw.style("bg-muted/15", indicatorClassName)}
33
34
  {...props}
@@ -42,4 +43,3 @@ export const BottomSheet = forwardRef<ActionSheetRef, BottomSheetProps>(
42
43
  );
43
44
  }
44
45
  );
45
- export { ActionSheetRef as BottomSheetRef };
@@ -0,0 +1,141 @@
1
+ import { zodResolver } from "@hookform/resolvers/zod";
2
+ import { cn, TextInputProps, View } from "@nativetail/core";
3
+ import React, { ComponentPropsWithoutRef } from "react";
4
+ import { Control, DefaultValues, useForm } from "react-hook-form";
5
+ import { z } from "zod";
6
+ import { Button } from "../button";
7
+ import {
8
+ FloatingInput,
9
+ FloatingInputProps,
10
+ Input,
11
+ InputProps,
12
+ PhoneInput,
13
+ PinInput,
14
+ PinInputProps,
15
+ } from "../input";
16
+ import { MultiSelect, MultiSelectProps, Select, SelectProps } from "../select";
17
+
18
+ export type FormBuilderProps<
19
+ T extends z.ZodRawShape,
20
+ IValues = z.infer<z.ZodObject<T>>,
21
+ > = {
22
+ schema: z.ZodObject<T>;
23
+ inputs?: Partial<
24
+ Record<
25
+ keyof T,
26
+ {
27
+ render?: (props: {
28
+ control: Control<IValues, any>;
29
+ name: keyof T;
30
+ }) => React.ReactElement;
31
+ } & InputType
32
+ >
33
+ >;
34
+ containerClassname?: string;
35
+ submitButtonProps?: ComponentPropsWithoutRef<typeof Button>;
36
+ onSubmit?: (values: IValues) => void;
37
+ onError?: (values: Partial<Record<keyof T, any>>) => void;
38
+ isSubmitting?: boolean;
39
+ defaultValues?: DefaultValues<IValues>;
40
+ };
41
+ export function FormBuilder<T extends z.ZodRawShape>({
42
+ schema,
43
+ inputs,
44
+ containerClassname,
45
+ submitButtonProps,
46
+ onSubmit,
47
+ onError,
48
+ isSubmitting,
49
+ defaultValues,
50
+ }: FormBuilderProps<T>) {
51
+ const shape = schema.shape;
52
+ const keys = Object.keys(shape);
53
+ type FormSchemaType = z.infer<typeof schema>;
54
+ const form = useForm<FormSchemaType>({
55
+ resolver: zodResolver(schema),
56
+ defaultValues: defaultValues,
57
+ });
58
+ return (
59
+ <View className={cn("gap-2", containerClassname)}>
60
+ {keys.map((inputKey) => {
61
+ const Input = inputs[inputKey];
62
+ const Render = Input.render;
63
+ if (!Render) {
64
+ return (
65
+ <InputComponent
66
+ control={form.control}
67
+ name={inputKey}
68
+ {...Input}
69
+ key={inputKey}
70
+ />
71
+ );
72
+ }
73
+ return <Render control={form.control} name={inputKey} key={inputKey} />;
74
+ })}
75
+ <Button
76
+ className="w-full"
77
+ children={"Submit"}
78
+ {...submitButtonProps}
79
+ onPress={form.handleSubmit(onSubmit, onError)}
80
+ isLoading={isSubmitting}
81
+ />
82
+ </View>
83
+ );
84
+ }
85
+ const InputComponent = ({
86
+ control,
87
+ name,
88
+ props,
89
+ type,
90
+ }: {
91
+ control: Control<any>;
92
+ name: string;
93
+ } & InputType) => {
94
+ switch (type) {
95
+ case "text":
96
+ return <Input control={control} name={name} {...props} />;
97
+ case "floating":
98
+ return <FloatingInput control={control} name={name} {...props} />;
99
+ case "select":
100
+ return <Select control={control} name={name} {...props} />;
101
+ case "multi-select":
102
+ return <MultiSelect control={control} name={name} {...props} />;
103
+ case "pin":
104
+ return <PinInput control={control} name={name} {...props} />;
105
+ case "phone":
106
+ return <PhoneInput control={control} name={name} {...props} />;
107
+ }
108
+ return null;
109
+ };
110
+
111
+ type TextInputType = {
112
+ type: "text";
113
+ props: InputProps;
114
+ };
115
+ type FloatingInputType = {
116
+ type: "floating";
117
+ props: FloatingInputProps;
118
+ };
119
+ type SelectInputType = {
120
+ type: "select";
121
+ props: SelectProps;
122
+ };
123
+ type MultiSelectInputType = {
124
+ type: "multi-select";
125
+ props: MultiSelectProps;
126
+ };
127
+ type PinInputType = {
128
+ type: "pin";
129
+ props: PinInputProps;
130
+ };
131
+ type PhoneInputType = {
132
+ type: "phone";
133
+ props: TextInputProps;
134
+ };
135
+ type InputType =
136
+ | TextInputType
137
+ | FloatingInputType
138
+ | SelectInputType
139
+ | MultiSelectInputType
140
+ | PinInputType
141
+ | PhoneInputType;
@@ -13,3 +13,4 @@ export * from "./blur";
13
13
  export * from "./progress";
14
14
  export * from "./counter";
15
15
  export * from "./tabs";
16
+ export * from "./form-builder";
@@ -1,3 +1,4 @@
1
1
  export * from "./floating-input";
2
2
  export * from "./input";
3
3
  export * from "./pin-input";
4
+ export * from "./phone-input";
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  cn,
3
+ Pressable,
3
4
  Text,
4
5
  TextInput,
5
6
  TextInputProps,
6
7
  useTw,
7
8
  View,
8
9
  } from "@nativetail/core";
9
- import React, { LegacyRef, useState } from "react";
10
+ import { MotiView } from "moti";
11
+ import React, { createRef, useState } from "react";
10
12
  import { Control, Controller, Path } from "react-hook-form";
11
13
  import { TextInput as NativeTextInput } from "react-native";
12
14
  import ShowPassword from "./show-password";
@@ -22,7 +24,9 @@ export type InputProps<T = Record<string, any>> = TextInputProps & {
22
24
  value?: string;
23
25
  control?: Control<T, any>;
24
26
  name?: Path<T>;
25
- inputRef?: LegacyRef<NativeTextInput>;
27
+ inputRef?: React.RefObject<NativeTextInput>;
28
+ inputContainerClassName?: string;
29
+ formatter?: (value: string) => string;
26
30
  };
27
31
 
28
32
  export const Input = <T extends Record<string, any>>({
@@ -64,7 +68,9 @@ const BaseInput = <T extends Record<string, any>>({
64
68
  rightElement,
65
69
  helperText,
66
70
  leftElement,
67
- inputRef,
71
+ inputRef = createRef<NativeTextInput>(),
72
+ inputContainerClassName,
73
+ formatter,
68
74
  ...props
69
75
  }: InputProps<T>) => {
70
76
  const tw = useTw();
@@ -72,34 +78,65 @@ const BaseInput = <T extends Record<string, any>>({
72
78
  const [showPassword, setShowPassword] = useState(
73
79
  isSecretToggleable ? false : true
74
80
  );
81
+ const [isFocused, setIsFocused] = useState(false);
75
82
  return (
76
83
  <View className={cn("w-full gap-1", containerClassName)}>
77
84
  <Text className={cn("text-muted/75 duration-75 text-sm")}>{label}</Text>
78
85
 
79
- <TextInput
80
- value={value}
81
- onChangeText={onChangeText}
82
- ref={inputRef}
86
+ <View
83
87
  className={cn(
84
- "p-3 bg-card rounded-lg w-full border border-muted/15 h-12 text-foreground -z-5 text-[16px]",
85
- className,
86
- isSecretToggleable || rightElement ? "pr-12" : "",
87
- leftElement ? "pl-12" : "",
88
- error && "border-danger"
88
+ " bg-card flex-row h-12 rounded-lg w-full border border-muted/15 ",
89
+ inputContainerClassName,
90
+ error && "border-danger",
91
+ isFocused && "border-foreground"
92
+ )}
93
+ >
94
+ {leftElement}
95
+ <TextInput
96
+ value={value}
97
+ onChangeText={onChangeText}
98
+ ref={inputRef}
99
+ className={cn(
100
+ " text-foreground p-2 rounded-lg flex-1 text-[16px]",
101
+ formatter && "opacity-0 scale-0 flex-0 absolute",
102
+ className
103
+ )}
104
+ placeholderTextColor={tw.color("muted")}
105
+ secureTextEntry={!showPassword}
106
+ {...props}
107
+ onFocus={(e) => {
108
+ setIsFocused(true);
109
+ props?.onFocus?.(e);
110
+ }}
111
+ onBlur={(e) => {
112
+ setIsFocused(false);
113
+ props?.onBlur?.(e);
114
+ }}
115
+ />
116
+ {formatter && (
117
+ <Pressable
118
+ className={cn(
119
+ " text-foreground p-2 flex-row rounded-lg text-left items-center h-full flex-1 text-[16px]",
120
+ className
121
+ )}
122
+ onPress={() => {
123
+ inputRef?.current?.focus();
124
+ }}
125
+ >
126
+ {formatter(value)}
127
+ {isFocused && <InputBlink />}
128
+ {!value && (
129
+ <Text className={cn("text-muted/75 absolute left-0 p-2")}>
130
+ {props.placeholder}
131
+ </Text>
132
+ )}
133
+ </Pressable>
89
134
  )}
90
- placeholderTextColor={tw.color("muted")}
91
- secureTextEntry={!showPassword}
92
- {...props}
93
- />
94
- {helperText && <Text className="text-muted text-sm">{helperText}</Text>}
95
135
 
96
- {leftElement && (
97
- <View className="absolute left-2 bottom-2">{leftElement}</View>
98
- )}
136
+ {rightElement}
137
+ </View>
138
+ {helperText && <Text className="text-muted text-sm">{helperText}</Text>}
99
139
 
100
- {rightElement && (
101
- <View className="absolute right-2 bottom-2">{rightElement}</View>
102
- )}
103
140
  {error && <Text className="text-danger text-sm">{error}</Text>}
104
141
 
105
142
  {isSecretToggleable && (
@@ -111,3 +148,28 @@ const BaseInput = <T extends Record<string, any>>({
111
148
  </View>
112
149
  );
113
150
  };
151
+
152
+ const InputBlink = () => {
153
+ const tw = useTw();
154
+ return (
155
+ <MotiView
156
+ style={{
157
+ width: 0.1,
158
+ height: "90%",
159
+ opacity: 1,
160
+ marginHorizontal: 0.8,
161
+ backgroundColor: tw.color("foreground/85"),
162
+ }}
163
+ from={{
164
+ opacity: 0,
165
+ }}
166
+ animate={{
167
+ opacity: 1,
168
+ }}
169
+ transition={{
170
+ duration: 650,
171
+ loop: true,
172
+ }}
173
+ />
174
+ );
175
+ };
@@ -0,0 +1,242 @@
1
+ import { cn, Pressable, Text, View } from "@nativetail/core";
2
+ import { memo, useCallback, useMemo, useRef, useState } from "react";
3
+ import { Controller } from "react-hook-form";
4
+ import { FlashList, FlatList } from "react-native-actions-sheet";
5
+ import { Iconify } from "react-native-iconify";
6
+ import { BottomSheet, BottomSheetRef } from "../bottom-sheet";
7
+ import { Input, InputProps } from "./input";
8
+
9
+ import { countries, ICountry } from "countries-list";
10
+ import { FloatingInput } from "./floating-input";
11
+ type CountryType = ICountry & {
12
+ code: string;
13
+ };
14
+ export const PhoneInput = <T extends Record<string, any>>({
15
+ name,
16
+ control,
17
+ ...props
18
+ }: InputProps<T>) => {
19
+ if (control) {
20
+ return (
21
+ <Controller
22
+ name={name}
23
+ control={control}
24
+ render={({ field, fieldState }) => {
25
+ return (
26
+ <BaseInput
27
+ {...props}
28
+ value={field.value}
29
+ onChangeText={(text) => {
30
+ field.onChange(text);
31
+ }}
32
+ error={fieldState.error?.message}
33
+ />
34
+ );
35
+ }}
36
+ />
37
+ );
38
+ }
39
+ return <BaseInput {...props} />;
40
+ };
41
+
42
+ const BaseInput = <T extends Record<string, any>>(props: InputProps<T>) => {
43
+ const [selectedCountry, setSelectedCountry] = useState<CountryType>({
44
+ ...countries.US,
45
+ code: "US",
46
+ });
47
+ return (
48
+ <Input
49
+ {...props}
50
+ leftElement={
51
+ <SelectCountry
52
+ selectedCountry={selectedCountry}
53
+ setSelectedCountry={setSelectedCountry}
54
+ />
55
+ }
56
+ placeholder="(123) 456-7890"
57
+ value={props.value}
58
+ formatter={(value) => {
59
+ // Remove all non-numeric characters
60
+ const cleaned = ("" + value).replace(/\D/g, "");
61
+
62
+ // Format the cleaned number
63
+ const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
64
+ if (match) {
65
+ return `(${match[1]}) ${match[2]}-${match[3]}`;
66
+ }
67
+
68
+ return value;
69
+ }}
70
+ onChangeText={(text) => {
71
+ props.onChangeText?.(text);
72
+ }}
73
+ />
74
+ );
75
+ };
76
+
77
+ const SelectCountry = ({
78
+ selectedCountry,
79
+ setSelectedCountry,
80
+ }: {
81
+ selectedCountry: CountryType;
82
+ setSelectedCountry: (country: CountryType) => void;
83
+ }) => {
84
+ const sheetRef = useRef<BottomSheetRef>(null);
85
+ const flag = useMemo(
86
+ () => selectedCountry && getFlagEmoji(selectedCountry.code),
87
+ [selectedCountry]
88
+ );
89
+ return (
90
+ <>
91
+ <Pressable
92
+ className=" p-2 h-full items-center justify-center flex-row gap-0.5 active:opacity-75 opacity-100 border-r border-muted/15 pr-1"
93
+ onPress={() => {
94
+ sheetRef.current?.show();
95
+ }}
96
+ >
97
+ <Text>
98
+ <Text className="mr-1">{flag}</Text>+{selectedCountry.phone?.[0]}
99
+ </Text>
100
+ <Iconify icon="mingcute:down-line" size={24} color="gray" />
101
+ </Pressable>
102
+ <CountryBottomSheet
103
+ selectedCountry={selectedCountry}
104
+ setSelectedCountry={setSelectedCountry}
105
+ sheetRef={sheetRef}
106
+ />
107
+ </>
108
+ );
109
+ };
110
+
111
+ const CountryBottomSheet = ({
112
+ selectedCountry,
113
+ setSelectedCountry,
114
+ sheetRef,
115
+ }: {
116
+ selectedCountry: CountryType;
117
+ setSelectedCountry: (code: CountryType) => void;
118
+ sheetRef: React.RefObject<BottomSheetRef>;
119
+ }) => {
120
+ const [search, setSearch] = useState("");
121
+ const [open, setOpen] = useState(false);
122
+ const allCountries = useMemo(
123
+ () =>
124
+ Object.entries(countries)
125
+ .map(([code, country]) => {
126
+ return {
127
+ code,
128
+ name: country.name,
129
+ ...country,
130
+ };
131
+ })
132
+ .filter((country) =>
133
+ country.name?.toLowerCase()?.includes(search?.toLowerCase())
134
+ ),
135
+ [search]
136
+ );
137
+ return (
138
+ <BottomSheet
139
+ ref={sheetRef}
140
+ onOpen={() => setOpen(true)}
141
+ onClose={() => setOpen(false)}
142
+ contentClassName=""
143
+ >
144
+ <View className="max-w-2xl mx-auto w-full max-h-[90%]">
145
+ {open && (
146
+ <CountryList
147
+ countries={allCountries}
148
+ search={search}
149
+ setSearch={setSearch}
150
+ selectedCountry={selectedCountry}
151
+ setSelectedCountry={setSelectedCountry}
152
+ close={() => {
153
+ sheetRef.current?.hide();
154
+ }}
155
+ />
156
+ )}
157
+ </View>
158
+ </BottomSheet>
159
+ );
160
+ };
161
+ const CountryList = memo(
162
+ ({
163
+ countries,
164
+ search,
165
+ setSearch,
166
+ selectedCountry,
167
+ setSelectedCountry,
168
+ close,
169
+ }: {
170
+ countries: CountryType[];
171
+ search: string;
172
+ setSearch: (search: string) => void;
173
+
174
+ selectedCountry: CountryType;
175
+ setSelectedCountry: (code: CountryType) => void;
176
+ close: () => void;
177
+ }) => {
178
+ const renderItem = useCallback(
179
+ ({
180
+ item,
181
+ }: {
182
+ item: ICountry & {
183
+ code: string;
184
+ };
185
+ }) => {
186
+ const isActive = selectedCountry.code === item.code;
187
+ return (
188
+ <Pressable
189
+ onPress={() => {
190
+ setSelectedCountry(item);
191
+ close();
192
+ }}
193
+ className={cn(
194
+ "p-3 bg-card rounded-xl border h-12 border-muted/15 flex-row gap-4 w-full",
195
+ isActive && "bg-primary/10 border-primary"
196
+ )}
197
+ containerClassName="w-full"
198
+ >
199
+ {getFlagEmoji(item.code)}
200
+ <Text>+{item.phone?.[0]}</Text>
201
+ <Text className="font-medium">{item.name}</Text>
202
+ </Pressable>
203
+ );
204
+ },
205
+ [selectedCountry, setSelectedCountry, close]
206
+ );
207
+
208
+ return (
209
+ <View className="overflow-x-hidden h-full">
210
+ <>
211
+ <Text className="text-center text-lg font-medium mb-2">
212
+ Select Country
213
+ </Text>
214
+ <FloatingInput
215
+ value={search}
216
+ onChangeText={setSearch}
217
+ label="Search Country"
218
+ containerClassName="mb-2"
219
+ />
220
+ </>
221
+ <View>
222
+ <FlashList
223
+ data={countries.slice(0, 15)}
224
+ renderItem={renderItem}
225
+ estimatedItemSize={100}
226
+ showsVerticalScrollIndicator
227
+ showsHorizontalScrollIndicator={false}
228
+ ItemSeparatorComponent={() => <View className="h-1" />}
229
+ keyExtractor={(item) => item.code}
230
+ />
231
+ </View>
232
+ </View>
233
+ );
234
+ }
235
+ );
236
+ function getFlagEmoji(countryCode) {
237
+ const codePoints = countryCode
238
+ .toUpperCase()
239
+ .split("")
240
+ .map((char) => 127397 + char.charCodeAt());
241
+ return String.fromCodePoint(...codePoints);
242
+ }
@@ -11,7 +11,7 @@ import { memo, useCallback, useMemo } from "react";
11
11
  import { Iconify } from "react-native-iconify";
12
12
  import { Control, Controller, Path } from "react-hook-form";
13
13
 
14
- type SelectProps<T extends Record<string, any>> = PressableProps & {
14
+ export type MultiSelectProps<T = Record<string, any>> = PressableProps & {
15
15
  containerClassName?: string;
16
16
  label?: string;
17
17
  error?: string;
@@ -31,7 +31,7 @@ export const MultiSelect = <T extends Record<string, any>>({
31
31
  name,
32
32
  control,
33
33
  ...props
34
- }: SelectProps<T>) => {
34
+ }: MultiSelectProps<T>) => {
35
35
  if (control) {
36
36
  return (
37
37
  <Controller
@@ -65,7 +65,7 @@ function BaseSelect<T extends Record<string, any>>({
65
65
  placeholder,
66
66
  options,
67
67
  ...props
68
- }: SelectProps<T>) {
68
+ }: MultiSelectProps<T>) {
69
69
  const tw = useTw();
70
70
  const renderOptions = useCallback(() => {
71
71
  return options.map((option, index) => (
@@ -112,11 +112,11 @@ const SelectTrigger = memo(
112
112
  error,
113
113
  ...props
114
114
  }: PressableProps & {
115
- options: SelectProps<T>["options"];
116
- value: SelectProps<T>["value"];
117
- onChange: SelectProps<T>["onChange"];
118
- placeholder: SelectProps<T>["placeholder"];
119
- error: SelectProps<T>["error"];
115
+ options: MultiSelectProps<T>["options"];
116
+ value: MultiSelectProps<T>["value"];
117
+ onChange: MultiSelectProps<T>["onChange"];
118
+ placeholder: MultiSelectProps<T>["placeholder"];
119
+ error: MultiSelectProps<T>["error"];
120
120
  }) => {
121
121
  const selectedOptions = useMemo(
122
122
  () => options.filter((option) => value?.includes?.(option.value)),
@@ -4,7 +4,7 @@ import { memo, useCallback, useMemo } from "react";
4
4
  import { Iconify } from "react-native-iconify";
5
5
  import { Control, Controller, Path } from "react-hook-form";
6
6
 
7
- type SelectProps<T extends Record<string, any>> = PressableProps & {
7
+ export type SelectProps<T = Record<string, any>> = PressableProps & {
8
8
  containerClassName?: string;
9
9
  label?: string;
10
10
  error?: string;
@@ -1,6 +1,7 @@
1
1
  import { cn, Pressable, Text, useTw, View } from "@nativetail/core";
2
2
  import { AnimatePresence } from "moti";
3
- import { useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { Modal } from "react-native";
4
5
  import { Iconify } from "react-native-iconify";
5
6
  import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7
  import { create } from "zustand";
@@ -19,6 +20,8 @@ type ToastType = {
19
20
 
20
21
  containerClassName?: string;
21
22
  type?: "success" | "danger" | "info" | "warning";
23
+ modal?: boolean;
24
+ icon?: React.ReactNode;
22
25
  };
23
26
  type InsertToastType = Omit<ToastType, "id">;
24
27
  type ToastStore = {
@@ -57,7 +60,6 @@ export function Toaster() {
57
60
  const bottomToasts = toasts.filter((toast) =>
58
61
  toast.position.includes("bottom")
59
62
  );
60
-
61
63
  const safeInsets = useSafeAreaInsets();
62
64
  return (
63
65
  <AnimatePresence exitBeforeEnter presenceAffectsLayout>
@@ -93,6 +95,34 @@ const Toast = (
93
95
  toast: ToastType & {
94
96
  index: number;
95
97
  }
98
+ ) => {
99
+ const safeInsets = useSafeAreaInsets();
100
+ const [visible, setVisible] = useState(true);
101
+ const close = useCallback(() => {
102
+ setVisible(false);
103
+ }, [setVisible]);
104
+ if (toast.modal)
105
+ return (
106
+ <Modal visible={visible} onRequestClose={close} transparent>
107
+ <Pressable
108
+ className={cn(
109
+ "absolute w-full h-full bg-black/15 left-0 justify-start z-50 gap-2",
110
+ toast.position.includes("top")
111
+ ? `pt-[${safeInsets.top + 10}px]`
112
+ : `pb-[${safeInsets.bottom + 10}px]`
113
+ )}
114
+ onPress={close}
115
+ >
116
+ <BaseToast {...toast} />
117
+ </Pressable>
118
+ </Modal>
119
+ );
120
+ return <BaseToast {...toast} />;
121
+ };
122
+ const BaseToast = (
123
+ toast: ToastType & {
124
+ index: number;
125
+ }
96
126
  ) => {
97
127
  const [open, setOpen] = useState(true);
98
128
  const tw = useTw();
@@ -136,7 +166,7 @@ const Toast = (
136
166
  ),
137
167
  };
138
168
 
139
- const Icon = Icons[toast.type];
169
+ const Icon = toast.icon || Icons[toast.type];
140
170
  const horizontalPositions = {
141
171
  center: "items-center",
142
172
  left: "items-start",